diff --git a/README.md b/README.md index 02d4420..1ec8d08 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # FParkan Открытый движок *Паркан: Железная стратегия* на Rust и Vulkan. Он читает -ресурсы установленной оригинальной игры. Сейчас приложение загружает миссию -и показывает её ландшафт и модели в статическом окне Vulkan. +ресурсы установленной оригинальной игры. Сейчас приложение показывает +интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты +объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые +события. Это просмотр карты с полной графической сценой; прохождение миссии и +игровое поведение юнитов пока не запускаются, а модели показываются в статической +позе, заданной сохранёнными настройками узлов прототипа. ## Запуск @@ -10,16 +14,45 @@ репозиторий не входят. ```powershell -cargo run -p fparkan-game -- ` +cargo run --release -p fparkan-game -- ` --root 'C:\GOG Games\Parkan - Iron Strategy' ` - --mission 'MISSIONS\Autodemo.00\data.tma' --frames 240 + --mission 'MISSIONS\Autodemo.00\data.tma' ``` -`--frames` задаёт число кадров. `--preview-roots N` ограничивает число объектов, -`--legacy-camera-capture` читает сохранённую матрицу камеры, -`--static-animation-frame` и `--static-material-phase` выбирают позу и фазу -материала. `--readback-out target/frame.raw` сохраняет последний кадр GPU. -Без сохранённой камеры используется вид карты сверху. +Окно работает в интерактивном режиме до закрытия и по умолчанию загружает все +корневые объекты миссии (`--preview-roots N` ограничивает их число только для +диагностики). +`WASD` перемещают камеру относительно её направления, `E/Q` поднимают и +опускают её, `Shift` ускоряет, `Ctrl` замедляет движение. Удержание правой +кнопки мыши включает relative-look; движение мыши вращает yaw/pitch. При +потере фокуса обзор отключается и нажатые клавиши сбрасываются. `Esc` +отпускает обзор или закрывает окно. + +В каталоге миссии ищется `sky.ske`. Если файл найден, приложение читает её +расписание и `sky.wea`; связанные MAT0/WEAR-материалы и +`effects.rlb/env_lightning` загружаются из ресурсов игры. Без `sky.ske` +окружение отключено; если файл есть, отсутствие `sky.wea`, `env_lightning` или +связанного материала считается ошибкой. Параметр `--atmosphere-seconds N` +задаёт начальное время; по умолчанию используется начало расписания. Когда +окружение загружено, небо, солнце и +луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные +звуковые события обновляются в каждом кадре. + +`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок; +значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture` +читает сохранённый захват камеры и запускает воспроизводимый режим без +free-flight управления. `--static-animation-frame ` задаёт один кадр для +всех узлов. Если параметр не указан, приложение берёт сохранённые настройки +узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки +используется кадр `0`. Статическая поза не запускает игровой Control, AI или +полное поведение юнитов. +`--static-material-phase ` выбирает фиксированную фазу материала. +`--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation` +включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении +или ошибке. Readback сохраняет байты в формате surface без преобразования; +размер — `width × height × 4`. Порядок каналов и encoding определяются значением +`readback_format` в выводе; на текущем GPU обычно используется +`VK_FORMAT_R8G8B8A8_UNORM`. ## Разработка diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index bcc3d43..89896cf 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -2,18 +2,132 @@ layout(location = 0) in vec3 in_color; layout(location = 1) in vec2 in_uv; +layout(location = 2) in vec2 in_detail_uv; +layout(location = 3) in float in_overlay_alpha; +layout(location = 4) in float in_fog_factor; +layout(location = 5) in vec3 in_base_diffuse; +layout(location = 6) in vec3 in_base_specular; +layout(location = 7) in vec3 in_overlay_diffuse; +layout(location = 8) in vec3 in_overlay_specular; layout(location = 0) out vec4 out_color; layout(set = 0, binding = 0) uniform sampler2D base_color; +layout(set = 0, binding = 1) uniform sampler2D base_detail; +layout(set = 0, binding = 2) uniform sampler2D overlay_color; +layout(set = 0, binding = 3) uniform sampler2D overlay_detail; -layout(push_constant) uniform AlphaTestConstants { - layout(offset = 64) - float alpha_cutoff; -} alpha_test; +layout(std140, set = 0, binding = 4) uniform FrameConstants { + mat4 clip_from_world; + vec4 directional_direction[4]; + vec4 directional_rgb[4]; + vec4 directional_coefficients[4]; + vec4 point_position_range; + vec4 point_rgb_active; + vec4 point_coefficients_active; + vec4 lighting_floor; + vec4 fog_color; + vec4 fog_distances; + vec4 camera_position; +} frame; + +layout(push_constant) uniform MaterialConstants { + layout(offset = 0) float alpha_cutoff; + layout(offset = 4) float diffuse_alpha; + layout(offset = 8) float overlay_diffuse_alpha; + layout(offset = 12) float directional_r; + layout(offset = 16) float directional_g; + layout(offset = 20) float directional_b; + layout(offset = 24) float additive_r; + layout(offset = 28) float additive_g; + layout(offset = 32) float additive_b; + layout(offset = 36) float overlay_directional_r; + layout(offset = 40) float overlay_directional_g; + layout(offset = 44) float overlay_directional_b; + layout(offset = 48) float overlay_additive_r; + layout(offset = 52) float overlay_additive_g; + layout(offset = 56) float overlay_additive_b; + layout(offset = 60) float base_page_x; + layout(offset = 64) float base_page_y; + layout(offset = 68) float base_page_w; + layout(offset = 72) float base_page_h; + layout(offset = 76) float detail_page_x; + layout(offset = 80) float detail_page_y; + layout(offset = 84) float detail_page_w; + layout(offset = 88) float detail_page_h; + layout(offset = 92) float overlay_page_x; + layout(offset = 96) float overlay_page_y; + layout(offset = 100) float overlay_page_w; + layout(offset = 104) float overlay_page_h; + layout(offset = 108) float overlay_detail_page_x; + layout(offset = 112) float overlay_detail_page_y; + layout(offset = 116) float overlay_detail_page_w; + layout(offset = 120) float overlay_detail_page_h; + layout(offset = 124) float material_mode; +} material; void main() { - out_color = texture(base_color, in_uv) * vec4(in_color, 1.0); - if (out_color.a < alpha_test.alpha_cutoff) { + vec2 base_uv = vec2(material.base_page_x, material.base_page_y) + + in_uv * vec2(material.base_page_w, material.base_page_h); + vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y) + + in_detail_uv * vec2(material.detail_page_w, material.detail_page_h); + vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y) + + in_uv * vec2(material.overlay_page_w, material.overlay_page_h); + vec2 overlay_detail_uv = vec2(material.overlay_detail_page_x, material.overlay_detail_page_y) + + in_detail_uv * vec2(material.overlay_detail_page_w, material.overlay_detail_page_h); + vec4 base = texture(base_color, base_uv); + vec4 detail = texture(base_detail, detail_uv); + vec4 overlay = texture(overlay_color, overlay_uv); + vec4 overlay_detail_sample = texture(overlay_detail, overlay_detail_uv); + int mode = int(round(material.material_mode)); + int combiner_mode = mode & 3; + + // Native sky mode 4 combines nebula and stars as + // mix(TEX0.rgb, TEX1.rgb, TEX1.a) * DIFFUSE.rgb and carries DIFFUSE.a + // through as the pass alpha. + if (combiner_mode == 2) { + vec3 sky_rgb = mix(base.rgb, detail.rgb, clamp(detail.a, 0.0, 1.0)); + out_color = vec4(sky_rgb * in_color, clamp(in_overlay_alpha, 0.0, 1.0)); + if (out_color.a < material.alpha_cutoff) { + discard; + } + return; + } + + // Native terrain mode 3 combines the base texture and the lightmap + // without the regular terrain 2x combiner or NdotL calculation. + if (combiner_mode == 3) { + vec3 lightmap_lighting = max( + vec3(material.additive_r, material.additive_g, material.additive_b), + frame.lighting_floor.rgb + ); + vec3 vertex_rgb = base.rgb * detail.rgb * lightmap_lighting * in_color; + vec3 final_rgb = mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor); + out_color = vec4(final_rgb, base.a * material.diffuse_alpha); + if (out_color.a < material.alpha_cutoff) { + discard; + } + return; + } + + float overlay_alpha = clamp( + in_overlay_alpha * material.overlay_diffuse_alpha, + 0.0, + 1.0 + ); + vec3 base_rgb = 2.0 * base.rgb * detail.rgb; + vec3 overlay_rgb = 2.0 * overlay.rgb * overlay_detail_sample.rgb; + vec3 base_lit_rgb = base_rgb * in_base_diffuse + in_base_specular; + vec3 overlay_lit_rgb = overlay_rgb * in_overlay_diffuse + in_overlay_specular; + vec3 lit_rgb = mix(base_lit_rgb, overlay_lit_rgb, overlay_alpha); + vec3 vertex_rgb = lit_rgb * in_color; + vec3 final_rgb = combiner_mode == 0 + ? mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor) + : vertex_rgb; + float final_alpha = combiner_mode == 1 + ? base.a * material.diffuse_alpha * in_overlay_alpha + : base.a * material.diffuse_alpha; + out_color = vec4(final_rgb, final_alpha); + if (out_color.a < material.alpha_cutoff) { discard; } } diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv index 46b1e6f..8b867bb 100644 Binary files a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv and b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv differ diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert index dfcdccb..201c31a 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.vert +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -2,17 +2,273 @@ layout(location = 0) in vec3 in_position; layout(location = 1) in vec3 in_color; -layout(location = 2) in vec2 in_uv; +layout(location = 2) in vec3 in_normal; +layout(location = 3) in vec2 in_uv; +layout(location = 4) in vec2 in_detail_uv; +layout(location = 5) in float in_overlay_alpha; layout(location = 0) out vec3 out_color; layout(location = 1) out vec2 out_uv; +layout(location = 2) out vec2 out_detail_uv; +layout(location = 3) out float out_overlay_alpha; +// Native D3D vertex fog is written to SPECULAR.a and interpolated by the +// fixed-function rasterizer. Keep the existing varying slot, but carry only +// that scalar instead of recomputing distance in the fragment stage. +layout(location = 4) out float out_fog_factor; +layout(location = 5) out vec3 out_base_diffuse; +layout(location = 6) out vec3 out_base_specular; +layout(location = 7) out vec3 out_overlay_diffuse; +layout(location = 8) out vec3 out_overlay_specular; -layout(push_constant) uniform CameraConstants { +layout(std140, set = 0, binding = 4) uniform FrameConstants { mat4 clip_from_world; -} camera; + vec4 directional_direction[4]; + vec4 directional_rgb[4]; + vec4 directional_coefficients[4]; + vec4 point_position_range; + vec4 point_rgb_active; + vec4 point_coefficients_active; + vec4 lighting_floor; + vec4 fog_color; + vec4 fog_distances; + vec4 camera_position; +} frame; + +layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular { + vec4 base; + vec4 overlay; +} material_specular; + +layout(push_constant) uniform MaterialConstants { + layout(offset = 0) float alpha_cutoff; + layout(offset = 4) float diffuse_alpha; + layout(offset = 8) float overlay_diffuse_alpha; + layout(offset = 12) float directional_r; + layout(offset = 16) float directional_g; + layout(offset = 20) float directional_b; + layout(offset = 24) float additive_r; + layout(offset = 28) float additive_g; + layout(offset = 32) float additive_b; + layout(offset = 36) float overlay_directional_r; + layout(offset = 40) float overlay_directional_g; + layout(offset = 44) float overlay_directional_b; + layout(offset = 48) float overlay_additive_r; + layout(offset = 52) float overlay_additive_g; + layout(offset = 56) float overlay_additive_b; + layout(offset = 60) float base_page_x; + layout(offset = 64) float base_page_y; + layout(offset = 68) float base_page_w; + layout(offset = 72) float base_page_h; + layout(offset = 76) float detail_page_x; + layout(offset = 80) float detail_page_y; + layout(offset = 84) float detail_page_w; + layout(offset = 88) float detail_page_h; + layout(offset = 92) float overlay_page_x; + layout(offset = 96) float overlay_page_y; + layout(offset = 100) float overlay_page_w; + layout(offset = 104) float overlay_page_h; + layout(offset = 108) float overlay_detail_page_x; + layout(offset = 112) float overlay_detail_page_y; + layout(offset = 116) float overlay_detail_page_w; + layout(offset = 120) float overlay_detail_page_h; + layout(offset = 124) float material_mode; +} material; + +struct LightingTerms { + vec3 diffuse; + vec3 specular; +}; + +float native_range_compress(float value) { + if (value <= 1.0) { + return value; + } + if (value <= 7.0) { + return value / 6.0 + 5.0 / 6.0; + } + return 2.0; +} + +float native_specular_curve(float value) { + if (value <= 1.0) { + return 0.8 * value; + } + if (value <= 3.0) { + return 0.1 * value + 0.7; + } + return 1.0; +} + +// The original callback uses the reflected vector and squares the cosine for +// power-1 iterations. A bounded loop keeps the native byte power while +// remaining valid for every material record. +float native_specular_power(float cosine, float power) { + // CShade enables specular only when the material power byte is non-zero. + if (power <= 0.0 || cosine <= 0.0) { + return 0.0; + } + float result = cosine; + int iterations = clamp(int(power) - 1, 0, 254); + for (int index = 0; index < 254; ++index) { + if (index >= iterations) { + break; + } + result *= result; + } + return result; +} + +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) { + return 0.0; + } + return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0); +} + +vec3 normalized_or(vec3 value, vec3 fallback) { + float length_squared = dot(value, value); + return length_squared > 0.0000001 ? value * inversesqrt(length_squared) : fallback; +} + +vec3 specular_from_light( + vec3 normal, + vec3 view_direction, + vec3 light_direction, + vec3 light_rgb, + vec3 material_rgb, + float power, + float attenuation +) { + float ndotl = dot(normal, light_direction); + if (ndotl <= 0.0 || attenuation <= 0.0) { + return vec3(0.0); + } + vec3 reflection = reflect(-light_direction, normal); + float cosine = max(dot(reflection, view_direction), 0.0); + return material_rgb * light_rgb * native_specular_power(cosine, power) * attenuation; +} + +LightingTerms native_material_lighting( + vec3 world_position, + vec3 normal, + vec3 directional_rgb, + vec3 additive_rgb, + vec3 specular_rgb, + float specular_power +) { + vec3 accumulated = additive_rgb; + vec3 specular = vec3(0.0); + vec3 view_direction = normalized_or(frame.camera_position.xyz - world_position, vec3(0.0, 0.0, 1.0)); + for (int index = 0; index < 4; ++index) { + vec4 direction_record = frame.directional_direction[index]; + vec4 rgb_record = frame.directional_rgb[index]; + if (direction_record.w <= 0.5 || rgb_record.w <= 0.5) { + continue; + } + vec3 light_direction = normalized_or(-direction_record.xyz, vec3(0.0, 0.0, 1.0)); + float ndotl = max(dot(normal, light_direction), 0.0); + accumulated += ndotl * directional_rgb * rgb_record.rgb; + specular += specular_from_light( + normal, + view_direction, + light_direction, + rgb_record.rgb, + specular_rgb, + specular_power, + 1.0 + ); + } + vec4 point_position_range = frame.point_position_range; + vec4 point_rgb_active = frame.point_rgb_active; + vec4 point_coefficients_active = frame.point_coefficients_active; + vec3 to_light = point_position_range.xyz - world_position; + float distance_squared = dot(to_light, to_light); + float range = point_position_range.w; + if (point_rgb_active.w > 0.5 && range > 0.0 && distance_squared <= range * range) { + float distance_to_light = sqrt(distance_squared); + if (distance_to_light > 0.000001) { + vec3 light_direction = to_light / distance_to_light; + float ndotl = dot(normal, light_direction); + if (ndotl > 0.0) { + float t = (range - distance_to_light) / range; + float attenuation = point_coefficients_active.x + + point_coefficients_active.y * t + + point_coefficients_active.z * t * t; + attenuation = max(attenuation, 0.0); + accumulated += ndotl * directional_rgb * point_rgb_active.rgb * attenuation; + specular += specular_from_light( + normal, + view_direction, + light_direction, + point_rgb_active.rgb, + specular_rgb, + specular_power, + attenuation + ); + } + } + } + accumulated = max(accumulated, frame.lighting_floor.rgb); + vec3 compressed = vec3( + native_range_compress(accumulated.r), + native_range_compress(accumulated.g), + native_range_compress(accumulated.b) + ); + LightingTerms terms; + terms.diffuse = min(compressed, vec3(1.0)); + vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0)); + terms.specular = vec3( + native_specular_curve(total_specular.r), + native_specular_curve(total_specular.g), + native_specular_curve(total_specular.b) + ); + return terms; +} void main() { out_color = in_color; out_uv = in_uv; - gl_Position = camera.clip_from_world * vec4(in_position, 1.0); + out_detail_uv = in_detail_uv; + out_overlay_alpha = in_overlay_alpha; + out_fog_factor = native_fog_factor(in_position); + int mode = int(round(material.material_mode)); + int combiner_mode = mode & 3; + if (combiner_mode == 0) { + LightingTerms base_terms = native_material_lighting( + in_position, + in_normal, + vec3(material.directional_r, material.directional_g, material.directional_b), + vec3(material.additive_r, material.additive_g, material.additive_b), + material_specular.base.rgb, + material_specular.base.w + ); + LightingTerms overlay_terms = native_material_lighting( + in_position, + in_normal, + vec3(material.overlay_directional_r, material.overlay_directional_g, material.overlay_directional_b), + vec3(material.overlay_additive_r, material.overlay_additive_g, material.overlay_additive_b), + material_specular.overlay.rgb, + material_specular.overlay.w + ); + out_base_diffuse = base_terms.diffuse; + out_base_specular = base_terms.specular; + out_overlay_diffuse = overlay_terms.diffuse; + out_overlay_specular = overlay_terms.specular; + } else { + out_base_diffuse = vec3(1.0); + out_base_specular = vec3(0.0); + out_overlay_diffuse = vec3(1.0); + out_overlay_specular = vec3(0.0); + } + vec4 clip_position = frame.clip_from_world * vec4(in_position, 1.0); + // Sky far-depth is a Vulkan projection choice for layers authored around + // the camera. Keep world positions, normals, and UVs intact while placing + // the resulting primitive at the far depth; this does not assert native + // D3D depth-bit equivalence. + if ((mode & 4) != 0) { + clip_position.z = clip_position.w; + } + gl_Position = clip_position; } diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv index 84d1d9f..9574e1c 100644 Binary files a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv and b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv differ diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 236361b..4ce86eb 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -1,16 +1,24 @@ //! Format-to-GPU geometry bridge for the initial static asset renderer. use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; +use fparkan_animation::{NodePoseBuffer, Pose}; use fparkan_msh::{ draw_batches, node38_fallback_hierarchy, node38_sampled_hierarchy, selected_slot, Group, Lod, ModelAsset, NodeId, }; -use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState}; +use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState}; use fparkan_terrain_format::LandMeshDocument; /// Legacy `Land.msh` stored-height to mission-world-height scale. const LEGACY_LAND_HEIGHT_SCALE: f32 = 1.0 / 32.0; +fn world_pipeline_state() -> LegacyPipelineState { + LegacyPipelineState { + depth: LegacyDepthMode::TestWrite, + ..LegacyPipelineState::default() + } +} + /// Error returned when a validated MSH cannot enter the current static GPU path. #[derive(Clone, Debug, Eq, PartialEq)] pub enum VulkanAssetMeshError { @@ -22,6 +30,22 @@ pub enum VulkanAssetMeshError { DegenerateViewExtent, /// The indexed model exceeds the current 16-bit Vulkan input contract. IndexOutOfRange, + /// A caller-supplied node pose buffer has a different length than its model. + NodePoseCountMismatch { + /// Number of nodes in the model. + expected: usize, + /// Number of supplied node poses. + actual: usize, + }, +} + +/// Evaluated world pose of one standard Node38 node in model-local space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanNodePose { + /// Translation in the model's local coordinate system. + pub translation: [f32; 3], + /// Unit quaternion in `[x, y, z, w]` order. + pub rotation: [f32; 4], } /// Shared XY frame for a deliberately top-down diagnostic static scene. @@ -83,6 +107,10 @@ impl std::fmt::Display for VulkanAssetMeshError { Self::NonFinitePosition => write!(f, "MSH contains a non-finite position"), Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XY view extent"), Self::IndexOutOfRange => write!(f, "MSH index exceeds the static Vulkan u16 contract"), + Self::NodePoseCountMismatch { expected, actual } => write!( + f, + "node pose buffer has {actual} poses, but the MSH has {expected} nodes" + ), } } } @@ -153,8 +181,9 @@ pub fn project_msh_to_static_mesh_in_xy_frame( frame.project(transformed)[1], 0.0, ], - color: [0.82, 0.72, 0.31], - // Iron3D stores Res5 UV0 as signed fixed point with 1/1024 units. + color: [1.0, 1.0, 1.0], + normal: model_normal(model, index), + // Iron3D stores Res5 UV0 as unsigned fixed point with 1/1024 units. // Models that omit this optional stream retain the static viewer's // XY planar fallback instead of receiving fabricated raw UV values. uv: model @@ -164,6 +193,8 @@ pub fn project_msh_to_static_mesh_in_xy_frame( .map_or(frame.planar_uv(transformed), |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: model_detail_uv(model, index), + overlay_alpha: 0.0, }) }) .collect::, _>>()?; @@ -281,6 +312,17 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform( model: &ModelAsset, transform: LegacyIron3dEulerTransform, scale: [f32; 3], +) -> Result { + project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model, transform, scale, None, + ) +} + +fn project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + root_pose: Option, ) -> Result { if !transform .translation @@ -300,12 +342,35 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform( if !position.iter().all(|value| value.is_finite()) { return Err(VulkanAssetMeshError::NonFinitePosition); } + let local_position = root_pose.map_or( + Pose { + translation: *position, + rotation: [0.0, 0.0, 0.0, 1.0], + }, + |root| { + compose_pose( + root, + Pose { + translation: *position, + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ) + }, + ); let position = transform - .try_transform_scaled_point(*position, scale) + .try_transform_scaled_point(local_position.translation, scale) .ok_or(VulkanAssetMeshError::NonFinitePosition)?; Ok(VulkanStaticVertex { position, - color: [0.82, 0.72, 0.31], + color: [1.0, 1.0, 1.0], + normal: transform_normal( + transform, + root_pose.map_or(model_normal(model, index), |root| { + rotate_by_quaternion(model_normal(model, index), root.rotation) + }), + scale, + ) + .ok_or(VulkanAssetMeshError::NonFinitePosition)?, uv: model .uv0 .as_ref() @@ -313,6 +378,8 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform( .map_or([0.0, 0.0], |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: model_detail_uv(model, index), + overlay_alpha: 0.0, }) }) .collect::, _>>()?; @@ -347,6 +414,8 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( transform, scale, node38_fallback_hierarchy(model), + None, + false, ) } @@ -372,6 +441,203 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( transform, scale, node38_sampled_hierarchy(model, animation_frame), + None, + false, + ) +} + +/// Projects a Node38 model using a hierarchy sampled by the caller. +/// +/// This keeps geometry in sync with any sockets or bounds that the caller +/// derives from the same pose buffer. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot +/// be represented by the current static Vulkan input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + node_poses: &NodePoseBuffer, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + Some(node_poses.clone()), + None, + false, + ) +} + +/// Projects a mounted Node38 model using a caller-sampled hierarchy and the +/// parent socket pose derived from its own caller-sampled hierarchy. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot +/// be represented by the current static Vulkan input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + node_poses: &NodePoseBuffer, + mount_pose: VulkanNodePose, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + Some(node_poses.clone()), + Some(Pose { + translation: mount_pose.translation, + rotation: mount_pose.rotation, + }), + true, + ) +} + +/// Evaluates one Node38 node pose for a unit mount socket. +#[must_use] +pub fn node38_pose( + model: &ModelAsset, + animation_frame: Option, + node_index: usize, +) -> Option { + node38_pose_with_root_replacement(model, animation_frame, node_index, false) +} + +/// Evaluates one Node38 socket relative to a replacement root pose. +/// +/// This is used when the model itself is already mounted: node zero belongs to +/// the parent socket and must not be composed a second time. +#[must_use] +pub fn node38_pose_relative_to_root( + model: &ModelAsset, + animation_frame: Option, + node_index: usize, +) -> Option { + node38_pose_with_root_replacement(model, animation_frame, node_index, true) +} + +/// Returns one node's evaluated pose from an already sampled hierarchy. +#[must_use] +pub fn node38_pose_from_hierarchy( + hierarchy: &NodePoseBuffer, + node_index: usize, +) -> Option { + node38_pose_value_from_hierarchy(hierarchy, node_index, false).map(vulkan_node_pose) +} + +/// Returns a node's pose relative to a root pose that will be replaced by a +/// parent socket. +#[must_use] +pub fn node38_pose_relative_to_root_from_hierarchy( + hierarchy: &NodePoseBuffer, + node_index: usize, +) -> Option { + node38_pose_value_from_hierarchy(hierarchy, node_index, true).map(vulkan_node_pose) +} + +fn node38_pose_value_from_hierarchy( + hierarchy: &NodePoseBuffer, + node_index: usize, + root_replaced: bool, +) -> Option { + let pose = *hierarchy.poses.get(node_index)?; + if !root_replaced { + return Some(pose); + } + if node_index == 0 { + return Some(Pose::default()); + } + let root = *hierarchy.poses.first()?; + Some(compose_pose(inverse_pose(root), pose)) +} + +fn vulkan_node_pose(pose: Pose) -> VulkanNodePose { + VulkanNodePose { + translation: pose.translation, + rotation: pose.rotation, + } +} + +fn node38_pose_with_root_replacement( + model: &ModelAsset, + animation_frame: Option, + node_index: usize, + root_replaced: bool, +) -> Option { + let hierarchy = animation_frame.map_or_else( + || node38_fallback_hierarchy(model), + |frame| node38_sampled_hierarchy(model, frame), + )?; + let hierarchy = if root_replaced { + strip_node38_root_pose(hierarchy)? + } else { + hierarchy + }; + let pose = *hierarchy.poses.get(node_index)?; + Some(VulkanNodePose { + translation: pose.translation, + rotation: pose.rotation, + }) +} + +/// Projects a mounted Node38 model while preserving its local hierarchy. +/// +/// The native unit loader mounts a child root at the parent's selected local +/// socket. The child root node itself is a socket placeholder and is hidden; +/// nodes `1..` retain their evaluated local hierarchy and are composed with the +/// parent's world socket pose. The mission transform remains applied once. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when geometry or transforms cannot be +/// represented by the static input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + animation_frame: u16, + mount_pose: VulkanNodePose, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + node38_sampled_hierarchy(model, animation_frame), + Some(Pose { + translation: mount_pose.translation, + rotation: mount_pose.rotation, + }), + true, + ) +} + +/// Projects a mounted Node38 model at its decoded fallback pose. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when geometry or transforms cannot be +/// represented by the static input contract. +pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount( + model: &ModelAsset, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + mount_pose: VulkanNodePose, +) -> Result { + project_msh_to_static_mesh_in_world_space_with_node_poses( + model, + transform, + scale, + node38_fallback_hierarchy(model), + Some(Pose { + translation: mount_pose.translation, + rotation: mount_pose.rotation, + }), + true, ) } @@ -380,7 +646,17 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( transform: LegacyIron3dEulerTransform, scale: [f32; 3], hierarchy: Option, + mount_pose: Option, + hide_root_node: bool, ) -> Result { + if let Some(hierarchy) = hierarchy.as_ref() { + if hierarchy.poses.len() != model.node_count { + return Err(VulkanAssetMeshError::NodePoseCountMismatch { + expected: model.node_count, + actual: hierarchy.poses.len(), + }); + } + } if !transform .translation .iter() @@ -391,10 +667,19 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( return Err(VulkanAssetMeshError::NonFinitePosition); } if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() { - return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); + return project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model, transform, scale, mount_pose, + ); } + let hierarchy = if mount_pose.is_some() { + hierarchy.and_then(strip_node38_root_pose) + } else { + hierarchy + }; let Some(hierarchy) = hierarchy else { - return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); + return project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose( + model, transform, scale, mount_pose, + ); }; let mut vertices = Vec::new(); @@ -406,7 +691,12 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( let Some(slot) = selected_slot(model, node, Lod(0), Group(0)) else { continue; }; - let pose = hierarchy.poses[node_index]; + if hide_root_node && node_index == 0 { + continue; + } + let pose = mount_pose.map_or(hierarchy.poses[node_index], |mount| { + compose_pose(mount, hierarchy.poses[node_index]) + }); if !pose .translation .iter() @@ -430,7 +720,16 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( } } if indices.is_empty() { - return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); + // A valid Node38 hierarchy may intentionally select no drawable + // batches for this component (for example, a mounted attachment + // whose root is hidden at the chosen LOD/group). Preserve that empty + // result instead of falling back to every source batch and drawing + // geometry that the node selection excluded. + return Ok(VulkanStaticMesh { + vertices, + indices, + draw_ranges, + }); } Ok(VulkanStaticMesh { vertices, @@ -439,6 +738,48 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( }) } +/// Replaces Node38 node zero with the identity pose while retaining every +/// descendant's pose relative to that root. Mounted components use this +/// because the native loader substitutes their root with the parent socket. +fn strip_node38_root_pose( + mut hierarchy: fparkan_animation::NodePoseBuffer, +) -> Option { + let root = *hierarchy.poses.first()?; + let inverse_root = inverse_pose(root); + hierarchy.poses[0] = Pose::default(); + for pose in hierarchy.poses.iter_mut().skip(1) { + *pose = compose_pose(inverse_root, *pose); + } + Some(hierarchy) +} + +fn inverse_pose(pose: Pose) -> Pose { + let [x, y, z, w] = pose.rotation; + let length_squared = x * x + y * y + z * z + w * w; + let inverse_rotation = if length_squared.is_finite() && length_squared > f32::EPSILON { + [ + -x / length_squared, + -y / length_squared, + -z / length_squared, + w / length_squared, + ] + } else { + [0.0, 0.0, 0.0, 1.0] + }; + let translation = rotate_by_quaternion( + [ + -pose.translation[0], + -pose.translation[1], + -pose.translation[2], + ], + inverse_rotation, + ); + Pose { + translation, + rotation: inverse_rotation, + } +} + fn append_node_fallback_batch( model: &ModelAsset, batch: &fparkan_msh::Batch, @@ -450,6 +791,15 @@ fn append_node_fallback_batch( indices: &mut Vec, draw_ranges: &mut Vec, ) -> Result<(), VulkanAssetMeshError> { + // Some authored MSH tables retain empty alternate batches. They do not + // describe a drawable range and would violate the renderer's contiguous + // non-empty draw-range contract when a full mission root set is loaded. + if batch.index_count == 0 { + return Ok(()); + } + if !usize::from(batch.index_count).is_multiple_of(3) { + return Err(VulkanAssetMeshError::IndexOutOfRange); + } let first_index = u32::try_from(indices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?; let start = @@ -488,7 +838,12 @@ fn append_node_fallback_batch( u32::try_from(vertices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?; vertices.push(VulkanStaticVertex { position, - color: [0.82, 0.72, 0.31], + color: [1.0, 1.0, 1.0], + normal: { + let normal = rotate_by_quaternion(model_normal(model, source_index), rotation); + transform_normal(transform, normal, scale) + .ok_or(VulkanAssetMeshError::NonFinitePosition)? + }, uv: model .uv0 .as_ref() @@ -496,19 +851,46 @@ fn append_node_fallback_batch( .map_or([0.0, 0.0], |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: model_detail_uv(model, source_index), + overlay_alpha: 0.0, }); indices.push(vertex_index); } draw_ranges.push(VulkanStaticDrawRange { first_index, index_count: u32::from(batch.index_count), - material_index: batch.material_index, - pipeline_state: LegacyPipelineState::default(), + material_index: u16::from(batch.material_index), + lightmap_index: batch.lightmap_index, + batch_flags: batch.batch_flags, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }); Ok(()) } +fn compose_pose(parent: Pose, child: Pose) -> Pose { + let translation = rotate_by_quaternion(child.translation, parent.rotation); + Pose { + translation: [ + parent.translation[0] + translation[0], + parent.translation[1] + translation[1], + parent.translation[2] + translation[2], + ], + rotation: multiply_quaternions(parent.rotation, child.rotation), + } +} + +fn multiply_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { + let [lx, ly, lz, lw] = left; + let [rx, ry, rz, rw] = right; + [ + lw * rx + lx * rw + ly * rz - lz * ry, + lw * ry - lx * rz + ly * rw + lz * rx, + lw * rz + lx * ry - ly * rx + lz * rw, + lw * rw - lx * rx - ly * ry - lz * rz, + ] +} + fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { let [x, y, z, w] = rotation; let tx = 2.0 * (y * position[2] - z * position[1]); @@ -521,6 +903,104 @@ fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { ] } +fn model_normal(model: &ModelAsset, index: usize) -> [f32; 3] { + model + .normals + .as_ref() + .and_then(|normals| normals.get(index)) + .map_or([0.0, 0.0, 1.0], |raw| { + // Indexed MSH stores signed bytes. The native decoder divides + // by 127 without clamping or renormalizing, so the packed length + // (including -128/127) reaches the shader unchanged. + [ + f32::from(raw[0]) / 127.0, + f32::from(raw[1]) / 127.0, + f32::from(raw[2]) / 127.0, + ] + }) +} + +/// Decodes the optional MSH type-18 secondary UV stream. Native model and +/// terrain samplers use the same unsigned 16-bit fixed-point 1/1024 scale for +/// both texture stages; an absent stream leaves the neutral detail coordinate. +fn model_detail_uv(model: &ModelAsset, index: usize) -> [f32; 2] { + model + .uv1 + .as_ref() + .and_then(|uv1| uv1.get(index)) + .map_or([0.0, 0.0], |uv| { + [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] + }) +} + +fn terrain_normal(terrain: &LandMeshDocument, index: usize) -> [f32; 3] { + terrain.normals.get(index).map_or([0.0, 0.0, 1.0], |raw| { + // Ngi32's normal decoder sign-extends each source byte and multiplies + // it by the literal 1/128. Preserve the packed magnitude here: the + // shader consumes the native vector directly, and normalizing it + // changes the lighting response for every authored byte. + [ + f32::from(raw[0]) * (1.0 / 128.0), + f32::from(raw[1]) * (1.0 / 128.0), + f32::from(raw[2]) * (1.0 / 128.0), + ] + }) +} + +fn transform_normal( + transform: LegacyIron3dEulerTransform, + normal: [f32; 3], + scale: [f32; 3], +) -> Option<[f32; 3]> { + if !normal.iter().all(|value| value.is_finite()) + || !scale + .iter() + .all(|value| value.is_finite() && value.abs() > f32::EPSILON) + { + return None; + } + // Positions use `R * (scale * point)`. Normals therefore use the + // inverse-transpose linear transform: inverse scale first, then R. + let inverse_scaled = [ + normal[0] / scale[0], + normal[1] / scale[1], + normal[2] / scale[2], + ]; + let matrix = transform.try_row_major()?; + let transformed = [ + matrix[0] * inverse_scaled[0] + + matrix[1] * inverse_scaled[1] + + matrix[2] * inverse_scaled[2], + matrix[4] * inverse_scaled[0] + + matrix[5] * inverse_scaled[1] + + matrix[6] * inverse_scaled[2], + matrix[8] * inverse_scaled[0] + + matrix[9] * inverse_scaled[1] + + matrix[10] * inverse_scaled[2], + ]; + let transformed_length = transformed[0].mul_add( + transformed[0], + transformed[1].mul_add(transformed[1], transformed[2] * transformed[2]), + ); + if !transformed_length.is_finite() || transformed_length <= f32::EPSILON { + return Some([0.0; 3]); + } + let source_length = normal[0].mul_add( + normal[0], + normal[1].mul_add(normal[1], normal[2] * normal[2]), + ); + if !source_length.is_finite() || source_length <= f32::EPSILON { + return Some([0.0; 3]); + } + let magnitude = source_length.sqrt(); + let inverse_transformed_length = transformed_length.sqrt().recip(); + Some([ + transformed[0] * inverse_transformed_length * magnitude, + transformed[1] * inverse_transformed_length * magnitude, + transformed[2] * inverse_transformed_length * magnitude, + ]) +} + /// Projects validated `Land.msh` terrain geometry into the static Vulkan path. /// /// This bridge preserves the source triangle order from `TerrainFace28` and @@ -595,13 +1075,16 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame( frame.project(*position)[1], 0.0, ], - color: [0.31, 0.58, 0.27], + color: [1.0, 1.0, 1.0], + normal: terrain_normal(terrain, index), uv: terrain .uv0 .get(index) .map_or(frame.planar_uv(*position), |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: terrain_detail_uv(terrain, index), + overlay_alpha: terrain_overlay_alpha(terrain, index), }) }) .collect::, _>>()?; @@ -638,10 +1121,13 @@ pub fn project_land_msh_to_static_mesh_in_world_space( } Ok(VulkanStaticVertex { position: *position, - color: [0.31, 0.58, 0.27], + color: [1.0, 1.0, 1.0], + normal: terrain_normal(terrain, index), uv: terrain.uv0.get(index).map_or([0.0, 0.0], |uv| { [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0] }), + detail_uv: terrain_detail_uv(terrain, index), + overlay_alpha: terrain_overlay_alpha(terrain, index), }) }) .collect::, _>>()?; @@ -695,6 +1181,38 @@ fn static_terrain_indices(terrain: &LandMeshDocument) -> Result, Vulkan Ok(indices) } +/// Decodes the terrain type-18 repeating detail coordinate. The original +/// stream stores two unsigned 16-bit fixed-point coordinates with the same +/// 1/1024 texture scale used by the texture sampler. +fn terrain_detail_uv(terrain: &LandMeshDocument, index: usize) -> [f32; 2] { + terrain.aux18.get(index).map_or([0.0, 0.0], |raw| { + [ + f32::from(u16::from_le_bytes([raw[0], raw[1]])) / 1024.0, + f32::from(u16::from_le_bytes([raw[2], raw[3]])) / 1024.0, + ] + }) +} + +/// Decodes the terrain type-14 overlay mask as the source float alpha. +/// Invalid or out-of-range values are disabled at the static bridge boundary; +/// valid map data is the continuous 0..1 mask consumed by the original shade +/// path. +fn terrain_overlay_alpha(terrain: &LandMeshDocument, index: usize) -> f32 { + terrain + .aux14 + .get(index) + .map_or(0.0, |raw| f32::from_le_bytes(*raw)) + .is_finite() + .then_some( + terrain + .aux14 + .get(index) + .map_or(0.0, |raw| f32::from_le_bytes(*raw)) + .clamp(0.0, 1.0), + ) + .unwrap_or(0.0) +} + fn static_terrain_draw_ranges( terrain: &LandMeshDocument, ) -> Result, VulkanAssetMeshError> { @@ -718,7 +1236,9 @@ fn static_terrain_draw_ranges( .checked_mul(3) .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, material_index, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }); start_face = end_face; @@ -733,6 +1253,12 @@ fn static_model_indices_and_ranges( let batches = static_model_preview_batches(model)?; let mut draw_ranges = Vec::with_capacity(batches.len()); for batch in batches { + if batch.index_count == 0 { + continue; + } + if !usize::from(batch.index_count).is_multiple_of(3) { + return Err(VulkanAssetMeshError::IndexOutOfRange); + } let first_index = u32::try_from(indices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?; let start = usize::try_from(batch.index_start) @@ -754,8 +1280,10 @@ fn static_model_indices_and_ranges( draw_ranges.push(VulkanStaticDrawRange { first_index, index_count: u32::from(batch.index_count), - material_index: batch.material_index, - pipeline_state: LegacyPipelineState::default(), + material_index: u16::from(batch.material_index), + lightmap_index: batch.lightmap_index, + batch_flags: batch.batch_flags, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }); } @@ -843,10 +1371,13 @@ mod tests { node_stride: 0, node_count: 0, nodes_raw: Vec::new(), + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, slots: Vec::new(), positions, normals: None, uv0: None, + uv1: None, indices, batches, node_names: None, @@ -857,12 +1388,13 @@ mod tests { fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> Batch { Batch { batch_flags: 0, + material_index_hi: 0, material_index: 0, - opaque4: 0, - opaque6: 0, + lightmap_index: 0, + local_batch_index: 0, index_count, index_start, - opaque14: 0, + vertex_count: 0, base_vertex, } } @@ -875,6 +1407,8 @@ mod tests { node_stride: 38, node_count: 1, nodes_raw: node, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, slots: vec![ Slot { tri_start: 0, @@ -902,6 +1436,7 @@ mod tests { positions: Vec::new(), normals: None, uv0: None, + uv1: None, indices: Vec::new(), batches: vec![ batch(0, 0, 0), @@ -940,7 +1475,9 @@ mod tests { first_index: 0, index_count: 3, material_index: 0, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: 0, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }] ); @@ -956,13 +1493,13 @@ mod tests { vec![0, 1, 2], vec![batch(0, 3, 0)], ); - source.uv0 = Some(vec![[1024, -512], [0, 2048], [-1024, 512]]); + source.uv0 = Some(vec![[1024, 65024], [0, 2048], [64512, 512]]); let mesh = project_msh_to_static_mesh(&source).expect("representable MSH"); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); assert_eq!(mesh.vertices[1].uv, [0.0, 2.0]); - assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); + assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]); } #[test] @@ -972,7 +1509,7 @@ mod tests { vec![0, 1, 2], vec![batch(0, 3, 0)], ); - source.uv0 = Some(vec![[1024, -512], [0, 2048], [-1024, 512]]); + source.uv0 = Some(vec![[1024, 65024], [0, 2048], [64512, 512]]); let mesh = project_msh_to_static_mesh_in_world_space( &source, @@ -984,7 +1521,7 @@ mod tests { assert_eq!(mesh.indices, vec![0, 1, 2]); assert_eq!(mesh.vertices[0].position, [104.0, 203.0, 302.0]); assert_eq!(mesh.vertices[1].position, [100.0, 199.0, 301.0]); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); } #[test] @@ -1010,6 +1547,65 @@ mod tests { assert_eq!(mesh.vertices[2].position, [10.0, 22.0, 30.0]); } + #[test] + fn world_space_msh_transforms_normals_with_inverse_scale() { + let mut source = model( + vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + vec![0, 1, 2], + vec![batch(0, 3, 0)], + ); + source.normals = Some(vec![[127, 127, 0, 0]; 3]); + + let mesh = project_msh_to_static_mesh_in_world_space_with_transform( + &source, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + [2.0, 3.0, 1.0], + ) + .expect("finite transformed normals"); + + let expected_magnitude = 2.0_f32.sqrt() * (127.0 / 127.0); + assert!((mesh.vertices[0].normal[0] - 0.83205 * expected_magnitude).abs() < 0.0001); + assert!((mesh.vertices[0].normal[1] - 0.55470 * expected_magnitude).abs() < 0.0001); + assert_eq!(mesh.vertices[0].normal[2], 0.0); + } + + #[test] + fn indexed_msh_normal_bytes_keep_packed_magnitude_through_uniform_scale() { + let mut source = model( + vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + vec![0, 1, 2], + vec![batch(0, 3, 0)], + ); + source.normals = Some(vec![[-128, 0, 0, 0], [64, 0, 0, 0], [0, 0, 127, 0]]); + let mesh = project_msh_to_static_mesh_in_world_space_with_transform( + &source, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2], + }, + [2.0; 3], + ) + .expect("finite packed normals"); + let lengths = mesh + .vertices + .iter() + .map(|vertex| { + vertex.normal[0] + .mul_add( + vertex.normal[0], + vertex.normal[1] + .mul_add(vertex.normal[1], vertex.normal[2] * vertex.normal[2]), + ) + .sqrt() + }) + .collect::>(); + assert!((lengths[0] - 128.0 / 127.0).abs() < 0.0001); + assert!((lengths[1] - 64.0 / 127.0).abs() < 0.0001); + } + #[test] fn xy_preview_applies_recovered_iron3d_orientation_before_framing() { let source = model( @@ -1048,6 +1644,8 @@ mod tests { node_stride: 38, node_count: 2, nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, slots: vec![ Slot { tri_start: 0, @@ -1075,6 +1673,7 @@ mod tests { positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], normals: None, uv0: None, + uv1: None, indices: vec![0, 1, 2, 0, 1, 2], batches: vec![batch(0, 3, 0), batch(3, 3, 0)], node_names: None, @@ -1112,6 +1711,248 @@ mod tests { assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]); } + #[test] + fn mounted_socket_pose_strips_only_a_replaced_parent_root() { + let mut nodes = vec![0_u8; 76]; + nodes[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + nodes[6..8].copy_from_slice(&0_u16.to_le_bytes()); + nodes[38 + 2..38 + 4].copy_from_slice(&0_u16.to_le_bytes()); + nodes[38 + 6..38 + 8].copy_from_slice(&1_u16.to_le_bytes()); + let source = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots: Vec::new(), + positions: Vec::new(), + normals: None, + uv0: None, + uv1: None, + indices: Vec::new(), + batches: Vec::new(), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![ + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [5.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [2.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + ], + frame_map: Vec::new(), + frame_count: 0, + }), + }; + + assert_eq!( + node38_pose(&source, None, 1) + .expect("full socket") + .translation, + [7.0, 0.0, 0.0] + ); + assert_eq!( + node38_pose_relative_to_root(&source, None, 1) + .expect("relative socket") + .translation, + [2.0, 0.0, 0.0] + ); + } + + #[test] + fn mounted_child_uses_precomputed_hierarchies_for_socket_and_geometry() { + let parent_hierarchy = NodePoseBuffer { + poses: vec![ + Pose { + translation: [20.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + Pose { + translation: [25.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ], + }; + let parent_socket = node38_pose_relative_to_root_from_hierarchy(&parent_hierarchy, 1) + .expect("sampled parent socket relative to its mounted root"); + assert_eq!(parent_socket.translation, [5.0, 0.0, 0.0]); + assert_eq!( + node38_pose_from_hierarchy(&parent_hierarchy, 1) + .expect("sampled parent socket") + .translation, + [25.0, 0.0, 0.0] + ); + + let mut child_root = vec![0_u8; 38]; + child_root[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + child_root[6..8].copy_from_slice(&0_u16.to_le_bytes()); + let mut child_node = vec![0_u8; 38]; + child_node[2..4].copy_from_slice(&0_u16.to_le_bytes()); + child_node[6..8].copy_from_slice(&1_u16.to_le_bytes()); + child_node[8..10].copy_from_slice(&1_u16.to_le_bytes()); + let mut nodes = child_root; + nodes.extend(child_node); + let child = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots: vec![ + Slot { + tri_start: 0, + tri_count: 0, + 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], + }, + Slot { + tri_start: 0, + tri_count: 0, + batch_start: 1, + 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], + }, + ], + positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + normals: None, + uv0: None, + uv1: None, + indices: vec![0, 1, 2, 0, 1, 2], + batches: vec![batch(0, 3, 0), batch(3, 3, 0)], + node_names: None, + animation: Some(ModelAnimation { + keys: vec![AnimKey24 { + time: AnimationTime(0.0), + pose: Pose::default(), + }], + frame_map: Vec::new(), + frame_count: 0, + }), + }; + let child_hierarchy = NodePoseBuffer { + poses: vec![ + Pose { + translation: [10.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + Pose { + translation: [14.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ], + }; + let child_socket = node38_pose_relative_to_root_from_hierarchy(&child_hierarchy, 1) + .expect("child node relative to its mounted root"); + assert_eq!(child_socket.translation, [4.0, 0.0, 0.0]); + + // The parent component's world root is at x=100, so its sampled socket + // at x=5 places the child root at x=105. The child geometry uses the + // same child hierarchy as the socket path: root x=10 is stripped and + // node 1 contributes x=4 before the source vertex at x=1. + let mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &child, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + &child_hierarchy, + VulkanNodePose { + translation: [105.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ) + .expect("mounted child geometry from sampled pose buffer"); + assert_eq!(mesh.vertices[0].position, [110.0, 0.0, 0.0]); + assert_eq!(mesh.indices, vec![0, 1, 2]); + } + + #[test] + fn supplied_node_pose_buffer_must_match_model_node_count() { + let source = model(Vec::new(), Vec::new(), Vec::new()); + let error = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &source, + LegacyIron3dEulerTransform { + translation: [0.0; 3], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + &NodePoseBuffer { + poses: vec![Pose::default()], + }, + ) + .expect_err("mismatched node pose count"); + assert_eq!( + error, + VulkanAssetMeshError::NodePoseCountMismatch { + expected: 0, + actual: 1, + } + ); + } + + #[test] + fn node_pose_projection_preserves_an_intentionally_empty_component() { + let mut node = vec![0_u8; 38]; + node[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + node[6..8].copy_from_slice(&0_u16.to_le_bytes()); + node[8..10].copy_from_slice(&u16::MAX.to_le_bytes()); + let source = ModelAsset { + node_stride: 38, + node_count: 1, + nodes_raw: node, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots: Vec::new(), + positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + normals: None, + uv0: None, + uv1: None, + indices: vec![0, 1, 2], + batches: vec![batch(0, 3, 0)], + node_names: None, + animation: Some(ModelAnimation { + keys: vec![AnimKey24 { + time: AnimationTime(0.0), + pose: Pose::default(), + }], + frame_map: Vec::new(), + frame_count: 0, + }), + }; + + let mesh = project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( + &source, + LegacyIron3dEulerTransform { + translation: [10.0, 20.0, 30.0], + orientation_radians: [0.0; 3], + }, + [1.0; 3], + ) + .expect("empty node component remains representable"); + assert!(mesh.vertices.is_empty()); + assert!(mesh.indices.is_empty()); + assert!(mesh.draw_ranges.is_empty()); + } + #[test] fn rotates_point_by_unit_quaternion() { let rotated = rotate_by_quaternion( @@ -1151,14 +1992,18 @@ mod tests { first_index: 0, index_count: 3, material_index: 0, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: 0, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }, VulkanStaticDrawRange { first_index: 3, index_count: 3, material_index: 7, - pipeline_state: LegacyPipelineState::default(), + lightmap_index: 0, + batch_flags: 0, + pipeline_state: world_pipeline_state(), alpha_test_reference: 0, }, ] @@ -1181,7 +2026,7 @@ mod tests { [2.0, 3.0, 1.0], ], normals: Vec::new(), - uv0: vec![[1024, -512], [0, 2048], [-1024, 512], [512, 0]], + uv0: vec![[1024, 65024], [0, 2048], [64512, 512], [512, 0]], accelerator: Vec::new(), aux14: Vec::new(), aux18: Vec::new(), @@ -1195,8 +2040,31 @@ mod tests { assert_eq!(mesh.draw_ranges[0].index_count, 6); assert_eq!(mesh.vertices[0].position, [-0.8, -0.8, 0.0]); assert_eq!(mesh.vertices[3].position, [0.8, 0.8, 0.0]); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); - assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); + assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]); + } + + #[test] + fn terrain_normals_keep_native_one_over_128_scale() { + let terrain = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: 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![[127, -128, 64, 0]; 3], + uv0: vec![[0, 0]; 3], + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![terrain_face([0, 1, 2])], + }; + + let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain) + .expect("representable terrain normals"); + assert_eq!(mesh.vertices[0].normal, [127.0 / 128.0, -1.0, 0.5]); } #[test] @@ -1210,7 +2078,7 @@ mod tests { }, positions: vec![[10.0, 20.0, 30.0], [40.0, 50.0, 60.0], [70.0, 80.0, 90.0]], normals: Vec::new(), - uv0: vec![[1024, -512], [0, 2048], [-1024, 512]], + uv0: vec![[1024, 65024], [0, 2048], [64512, 512]], accelerator: Vec::new(), aux14: Vec::new(), aux18: Vec::new(), @@ -1223,7 +2091,7 @@ mod tests { assert_eq!(mesh.indices, vec![2, 0, 1]); assert_eq!(mesh.vertices[0].position, [10.0, 20.0, 30.0]); assert_eq!(mesh.vertices[2].position, [70.0, 80.0, 90.0]); - assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); } #[test] diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 640551a..83fdbbf 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -21,16 +21,22 @@ mod swapchain_resources; mod validation; pub use self::asset_mesh::{ - project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space, + node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, + node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh, + project_land_msh_to_static_mesh_in_legacy_world_space, 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, + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount, + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer, + 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, project_msh_to_static_mesh_in_world_space_with_transform, project_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError, - VulkanStaticXyFrame, + VulkanNodePose, VulkanStaticXyFrame, }; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, @@ -45,21 +51,27 @@ pub use self::instance::{ use self::instance::{cstring_vec, ensure_instance_extensions_available}; use self::resources::{ color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync, + create_frame_uniform_buffer, create_index_buffer, create_material_specular_buffer, create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, - create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image, - destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage, - VulkanDepthAttachment, VulkanFrameSync, + create_static_texture_image, create_vertex_buffer, destroy_allocated_buffer, + destroy_allocated_image, destroy_depth_attachment, readback_buffer_bytes, + update_static_texture_image, write_frame_uniform, write_material_specular_buffer, + write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, + VulkanAllocatedImage, VulkanDepthAttachment, VulkanFrameSync, }; pub use self::runtime::{ create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport, }; pub use self::smoke_types::{ + VulkanDirectionalLight, VulkanDynamicDrawRange, VulkanFrameUniforms, VulkanPointLight, VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, - VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport, + VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, VulkanValidationReport, + VULKAN_DIRECTIONAL_LIGHT_COUNT, VULKAN_FRAME_UNIFORM_BYTES, + VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, }; #[cfg(test)] use self::surface::extension_name; @@ -72,7 +84,8 @@ pub use self::swapchain::{ VulkanSwapchainProbeError, VulkanSwapchainReport, }; use self::swapchain_resources::{ - create_swapchain_resources, destroy_swapchain_resources, VulkanSwapchainResources, + create_swapchain_resources, destroy_swapchain_resources, update_material_texture_descriptor, + VulkanSwapchainResources, }; use self::validation::{create_validation_messenger, VulkanValidationMessenger}; use ash::vk; @@ -469,6 +482,8 @@ const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[ ]; const fn spirv_words(bytes: &[u8]) -> [u32; WORD_COUNT] { + assert!(bytes.len() % 4 == 0); + assert!(bytes.len() == WORD_COUNT * 4); let mut words = [0_u32; WORD_COUNT]; let mut index = 0; while index < WORD_COUNT { @@ -484,10 +499,12 @@ const fn spirv_words(bytes: &[u8]) -> [u32; WORD_COUNT] words } -static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] = - spirv_words(include_bytes!("../shaders/triangle.vert.spv")); -static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] = - spirv_words(include_bytes!("../shaders/triangle.frag.spv")); +const TRIANGLE_VERTEX_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.vert.spv"); +const TRIANGLE_FRAGMENT_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.frag.spv"); +static TRIANGLE_VERTEX_SHADER_DATA: [u32; TRIANGLE_VERTEX_SHADER_BYTES.len() / 4] = + spirv_words(TRIANGLE_VERTEX_SHADER_BYTES); +static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; TRIANGLE_FRAGMENT_SHADER_BYTES.len() / 4] = + spirv_words(TRIANGLE_FRAGMENT_SHADER_BYTES); pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA; pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA; diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index f1fd579..b841588 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -18,6 +18,9 @@ pub(super) struct VulkanAllocatedImage { pub(super) image: vk::Image, pub(super) memory: vk::DeviceMemory, pub(super) view: vk::ImageView, + pub(super) width: u32, + pub(super) height: u32, + pub(super) mip_levels: u32, } /// Depth/stencil image and the exact format selected for its render pass. @@ -134,6 +137,9 @@ pub(super) fn create_depth_attachment( image, memory, view, + width: extent.0, + height: extent.1, + mip_levels: 1, }, format, }) @@ -186,7 +192,6 @@ fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags { pub(super) struct VulkanFrameSync { pub(super) image_available: vk::Semaphore, - pub(super) render_finished: vk::Semaphore, pub(super) fence: vk::Fence, } @@ -210,17 +215,7 @@ pub(super) fn create_static_mesh_vertex_buffer( device: &VulkanLogicalDeviceProbe, mesh: &VulkanStaticMesh, ) -> Result { - let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::()); - for vertex in &mesh.vertices { - for value in vertex - .position - .into_iter() - .chain(vertex.color) - .chain(vertex.uv) - { - bytes.extend_from_slice(&value.to_ne_bytes()); - } - } + let bytes = encode_static_vertices(&mesh.vertices)?; create_host_visible_buffer( instance, device, @@ -230,34 +225,176 @@ pub(super) fn create_static_mesh_vertex_buffer( ) } +/// Allocates a vertex buffer for a frame's retained mesh capacity. +pub(super) fn create_vertex_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + vertices: &[super::VulkanStaticVertex], +) -> Result { + let bytes = encode_static_vertices(vertices)?; + create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::VERTEX_BUFFER, + "dynamic mesh 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 +/// 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, + vertices: &[super::VulkanStaticVertex], +) -> Result<(), VulkanSmokeRendererError> { + let bytes = encode_static_vertices(vertices)?; + write_mapped_buffer( + device, + buffer, + &bytes, + "vkMapMemory(static mesh vertex update)", + ) +} + +fn encode_static_vertices( + vertices: &[super::VulkanStaticVertex], +) -> Result, VulkanSmokeRendererError> { + let byte_len = vertices + .len() + .checked_mul(14) + .and_then(|count| count.checked_mul(std::mem::size_of::())) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh vertex data size overflows address space", + })?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(byte_len) + .map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh vertex data exceeds addressable memory", + })?; + for vertex in vertices { + for value in vertex + .position + .into_iter() + .chain(vertex.color) + .chain(vertex.normal) + .chain(vertex.uv) + .chain(vertex.detail_uv) + .chain([vertex.overlay_alpha]) + { + bytes.extend_from_slice(&value.to_ne_bytes()); + } + } + Ok(bytes) +} + +fn write_mapped_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + 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", + }); + } + // 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()) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { context, result })?; + // SAFETY: `mapped` covers exactly the byte slice copied below. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::(), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) +} + pub(super) fn create_static_mesh_index_buffer( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, mesh: &VulkanStaticMesh, ) -> Result { - let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::()); - for &index in &mesh.indices { - bytes.extend_from_slice(&index.to_ne_bytes()); - } + create_index_buffer(instance, device, &mesh.indices) +} + +/// Allocates an index buffer for a frame's retained mesh capacity. +pub(super) fn create_index_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + indices: &[u32], +) -> Result { + let bytes = encode_static_indices(indices)?; create_host_visible_buffer( instance, device, &bytes, vk::BufferUsageFlags::INDEX_BUFFER, - "static mesh index buffer", + "dynamic mesh index buffer", ) } +/// Replaces the bytes of a host-visible static index allocation. +pub(super) fn write_static_mesh_index_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + indices: &[u32], +) -> Result<(), VulkanSmokeRendererError> { + let bytes = encode_static_indices(indices)?; + write_mapped_buffer( + device, + buffer, + &bytes, + "vkMapMemory(static mesh index update)", + ) +} + +fn encode_static_indices(indices: &[u32]) -> Result, VulkanSmokeRendererError> { + let byte_len = indices + .len() + .checked_mul(std::mem::size_of::()) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh index data size overflows address space", + })?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(byte_len) + .map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "static mesh index data exceeds addressable memory", + })?; + for &index in indices { + bytes.extend_from_slice(&index.to_ne_bytes()); + } + Ok(bytes) +} + pub(super) fn create_static_texture_image( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, command_pool: vk::CommandPool, texture: &VulkanStaticTexture, ) -> Result { + texture + .validate() + .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?; + let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture); let staging = create_host_visible_buffer( instance, device, - &texture.rgba8, + &mip_bytes, vk::BufferUsageFlags::TRANSFER_SRC, "static texture staging buffer", )?; @@ -269,7 +406,7 @@ pub(super) fn create_static_texture_image( height: texture.height, depth: 1, }) - .mip_levels(1) + .mip_levels(mip_levels) .array_layers(1) .samples(vk::SampleCountFlags::TYPE_1) .tiling(vk::ImageTiling::OPTIMAL) @@ -336,6 +473,7 @@ pub(super) fn create_static_texture_image( image, texture.width, texture.height, + mip_levels, ) { // SAFETY: Both resources were created on this device and are being rolled back once. unsafe { @@ -350,7 +488,7 @@ pub(super) fn create_static_texture_image( .image(image) .view_type(vk::ImageViewType::TYPE_2D) .format(vk::Format::R8G8B8A8_UNORM) - .subresource_range(color_subresource_range()); + .subresource_range(color_subresource_range(mip_levels)); // SAFETY: The image is live, initialized and has the stated color subresource. let view = { // SAFETY: The image is live, initialized and has the stated color subresource. @@ -371,9 +509,211 @@ pub(super) fn create_static_texture_image( image, memory, view, + width: texture.width, + height: texture.height, + mip_levels, }) } +/// Allocates two aligned camera slots used by the dynamic frame UBO. +pub(super) fn create_frame_uniform_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, +) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> { + const FRAME_SLOTS: u64 = 2; + const FRAME_BYTES: u64 = super::VULKAN_FRAME_UNIFORM_BYTES as u64; + let limits = + // SAFETY: The physical device belongs to this live instance and the query returns a value copy. + unsafe { instance.instance.get_physical_device_properties(device.physical_device()) }; + let alignment = u64::from(limits.limits.min_uniform_buffer_offset_alignment).max(1); + let stride = FRAME_BYTES.checked_add(alignment - 1).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform stride overflow", + }, + )? / alignment + * alignment; + let size = + stride + .checked_mul(FRAME_SLOTS) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform buffer size overflow", + })?; + let bytes = vec![ + 0_u8; + usize::try_from(size).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "frame uniform buffer size exceeds address space", + } + })? + ]; + let buffer = create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::UNIFORM_BUFFER, + "frame uniform buffer", + )?; + Ok((buffer, stride)) +} + +/// Allocates one aligned storage-buffer record for every material descriptor. +/// +/// The descriptor uses a dynamic storage-buffer offset. Keeping the stride +/// aligned to the physical device limit lets the renderer select a material +/// record with the same descriptor set for every draw without baking material +/// indices into the pipeline layout. +pub(super) fn create_material_specular_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + material_count: usize, +) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> { + if material_count == 0 { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "material specular buffer has no material records", + }); + } + let limits = + // SAFETY: The physical device belongs to this live instance and the query returns a value copy. + unsafe { instance.instance.get_physical_device_properties(device.physical_device()) }; + let alignment = limits.limits.min_storage_buffer_offset_alignment.max(1); + let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64; + let stride = record_bytes.checked_add(alignment - 1).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular stride overflow", + }, + )? / alignment + * alignment; + let count = + u64::try_from(material_count).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular count exceeds address space", + })?; + let size = stride + .checked_mul(count) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular buffer size overflow", + })?; + let bytes = + vec![ + 0_u8; + usize::try_from(size).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh { + context: "material specular buffer size exceeds address space", + })? + ]; + let buffer = create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::STORAGE_BUFFER, + "material specular buffer", + )?; + Ok((buffer, stride)) +} + +/// Replaces all material specular records in one host-coherent upload. +pub(super) fn write_material_specular_buffer( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + stride: u64, + records: &[[[f32; 4]; 2]], +) -> Result<(), VulkanSmokeRendererError> { + if records.is_empty() { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload has no records", + }); + } + let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES; + let total_bytes = stride + .checked_mul(u64::try_from(records.len()).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload size", + })?; + let total_bytes_usize = + usize::try_from(total_bytes).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload size exceeds address space", + })?; + let mut bytes = vec![0_u8; total_bytes_usize]; + for (record_index, record) in records.iter().enumerate() { + let record_offset = usize::try_from( + stride + .checked_mul(u64::try_from(record_index).unwrap_or(u64::MAX)) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload offset", + })?, + ) + .map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "material specular upload offset exceeds address space", + })?; + let mut offset = record_offset; + for vector in record { + for value in vector { + bytes[offset..offset + std::mem::size_of::()] + .copy_from_slice(&value.to_ne_bytes()); + offset += std::mem::size_of::(); + } + } + debug_assert_eq!(offset - record_offset, record_bytes); + } + // SAFETY: The storage buffer is host-visible and the caller has waited for + // all prior uses before replacing its records. + let mapped = unsafe { + device + .device() + .map_memory(buffer.memory, 0, total_bytes, vk::MemoryMapFlags::empty()) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkMapMemory(material specular buffer)", + result, + })?; + // SAFETY: The mapped range is the complete upload and the destination is + // valid for the exact byte count copied below. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::(), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) +} + +/// Writes one complete frame block into the slot selected by the +/// fence-safe frame index. +pub(super) fn write_frame_uniform( + device: &VulkanLogicalDeviceProbe, + buffer: &VulkanAllocatedBuffer, + stride: u64, + frame_index: usize, + bytes: &[u8; super::VULKAN_FRAME_UNIFORM_BYTES], +) -> Result<(), VulkanSmokeRendererError> { + let slot = + u64::try_from(frame_index).map_err(|_| VulkanSmokeRendererError::InvariantViolation { + context: "frame uniform slot index", + })?; + let offset = stride + .checked_mul(slot) + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "frame uniform slot offset", + })?; + // SAFETY: The offset is one of the two allocated aligned slots and the + // mapped range is exactly one complete frame block. + let mapped = unsafe { + device.device().map_memory( + buffer.memory, + offset, + super::VULKAN_FRAME_UNIFORM_BYTES as u64, + vk::MemoryMapFlags::empty(), + ) + } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkMapMemory(frame uniform buffer)", + result, + })?; + // SAFETY: The destination is the mapped frame slot and the byte slice lives + // for this copy. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::(), bytes.len()); + device.device().unmap_memory(buffer.memory); + } + Ok(()) +} + fn upload_static_texture( device: &VulkanLogicalDeviceProbe, command_pool: vk::CommandPool, @@ -381,6 +721,7 @@ fn upload_static_texture( image: vk::Image, width: u32, height: u32, + mip_levels: u32, ) -> Result<(), VulkanSmokeRendererError> { let allocate_info = vk::CommandBufferAllocateInfo::default() .command_pool(command_pool) @@ -407,7 +748,7 @@ fn upload_static_texture( result, }); } - let range = color_subresource_range(); + let range = color_subresource_range(mip_levels); let to_transfer = vk::ImageMemoryBarrier::default() .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) @@ -415,19 +756,34 @@ fn upload_static_texture( .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE) .image(image) .subresource_range(range); - let region = vk::BufferImageCopy::default() - .image_subresource( - vk::ImageSubresourceLayers::default() - .aspect_mask(vk::ImageAspectFlags::COLOR) - .mip_level(0) - .base_array_layer(0) - .layer_count(1), - ) - .image_extent(vk::Extent3D { - width, - height, - depth: 1, - }); + let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0)); + let mut buffer_offset = 0_u64; + let mut mip_width = width; + let mut mip_height = height; + for mip_level in 0..mip_levels { + let mip_bytes = u64::from(mip_width) + .saturating_mul(u64::from(mip_height)) + .saturating_mul(4); + regions.push( + vk::BufferImageCopy::default() + .buffer_offset(buffer_offset) + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(mip_level) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: mip_width, + height: mip_height, + depth: 1, + }), + ); + buffer_offset = buffer_offset.saturating_add(mip_bytes); + mip_width = (mip_width / 2).max(1); + mip_height = (mip_height / 2).max(1); + } let to_sampled = vk::ImageMemoryBarrier::default() .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) @@ -451,7 +807,7 @@ fn upload_static_texture( staging_buffer, image, vk::ImageLayout::TRANSFER_DST_OPTIMAL, - &[region], + ®ions, ); device.device().cmd_pipeline_barrier( command_buffer, @@ -500,6 +856,246 @@ fn upload_static_texture( }) } +/// Replaces the contents of an already sampled texture without changing its +/// image view or descriptor set. The caller must wait for the renderer's +/// submitted work before invoking this function. +pub(super) fn update_static_texture_image( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + command_pool: vk::CommandPool, + image: &VulkanAllocatedImage, + texture: &VulkanStaticTexture, +) -> Result<(), VulkanSmokeRendererError> { + texture + .validate() + .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?; + let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture); + if texture.width != image.width + || texture.height != image.height + || mip_levels != image.mip_levels + { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic texture extent or mip count differs from allocation", + }); + } + let staging = create_host_visible_buffer( + instance, + device, + &mip_bytes, + vk::BufferUsageFlags::TRANSFER_SRC, + "dynamic texture staging buffer", + )?; + let allocate_info = vk::CommandBufferAllocateInfo::default() + .command_pool(command_pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1); + // SAFETY: The command pool is live and owned by the current logical device. + let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) } + .map_err(|result| { + destroy_allocated_buffer(device, &staging); + VulkanSmokeRendererError::VulkanOperation { + context: "vkAllocateCommandBuffers(dynamic texture upload)", + result, + } + })?[0]; + let begin = + vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT); + // SAFETY: The command buffer is freshly allocated from the current pool. + if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } { + // SAFETY: The command buffer belongs to this pool and is released on failure. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + }; + destroy_allocated_buffer(device, &staging); + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkBeginCommandBuffer(dynamic texture upload)", + result, + }); + } + let range = color_subresource_range(mip_levels); + let to_transfer = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .src_access_mask(vk::AccessFlags::SHADER_READ) + .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .image(image.image) + .subresource_range(range); + let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0)); + let mut buffer_offset = 0_u64; + let mut mip_width = texture.width; + let mut mip_height = texture.height; + for mip_level in 0..mip_levels { + let mip_bytes = u64::from(mip_width) + .saturating_mul(u64::from(mip_height)) + .saturating_mul(4); + regions.push( + vk::BufferImageCopy::default() + .buffer_offset(buffer_offset) + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(mip_level) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: mip_width, + height: mip_height, + depth: 1, + }), + ); + buffer_offset = buffer_offset.saturating_add(mip_bytes); + mip_width = (mip_width / 2).max(1); + mip_height = (mip_height / 2).max(1); + } + let to_sampled = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(image.image) + .subresource_range(range); + // SAFETY: The caller has made all prior shader reads idle, and the image + // remains exclusively owned by this graphics queue. + unsafe { + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_transfer], + ); + device.device().cmd_copy_buffer_to_image( + command_buffer, + staging.buffer, + image.image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ®ions, + ); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_sampled], + ); + } + // SAFETY: Command recording is complete on the current command buffer. + if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } { + // SAFETY: The command buffer belongs to this pool and is released on failure. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + }; + destroy_allocated_buffer(device, &staging); + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkEndCommandBuffer(dynamic texture upload)", + result, + }); + } + let command_buffers = [command_buffer]; + let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)]; + // SAFETY: The graphics queue and submitted command buffer remain live + // until this synchronous wait completes. + let submit_result = unsafe { + device + .device() + .queue_submit(device.graphics_queue(), &submit, vk::Fence::null()) + }; + let result = submit_result + .and_then(|()| unsafe { device.device().queue_wait_idle(device.graphics_queue()) }); + // SAFETY: Submission completed or failed synchronously. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + } + destroy_allocated_buffer(device, &staging); + result.map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkQueueSubmit/WaitIdle(dynamic texture upload)", + result, + }) +} + +/// Builds an RGBA8 mip chain on the CPU so texture upload does not depend on +/// optional linear-blit support for the device's UNORM format. +fn build_static_texture_mip_chain(texture: &VulkanStaticTexture) -> (Vec, u32) { + if !texture.mip_levels.is_empty() { + let mut bytes = Vec::new(); + for mip in &texture.mip_levels { + bytes.extend_from_slice(&mip.rgba8); + } + return ( + bytes, + u32::try_from(texture.mip_levels.len()).unwrap_or(u32::MAX), + ); + } + let mut bytes = texture.rgba8.clone(); + let mut level = texture.rgba8.clone(); + let mut width = texture.width; + let mut height = texture.height; + let mut mip_levels = 1_u32; + + while width > 1 || height > 1 { + let next_width = (width / 2).max(1); + let next_height = (height / 2).max(1); + let mut next = vec![ + 0_u8; + usize::try_from(next_width) + .unwrap_or(0) + .saturating_mul(usize::try_from(next_height).unwrap_or(0)) + .saturating_mul(4) + ]; + for y in 0..next_height { + for x in 0..next_width { + let dst = (usize::try_from(y).unwrap_or(0) + * usize::try_from(next_width).unwrap_or(0) + + usize::try_from(x).unwrap_or(0)) + * 4; + let x0 = x.saturating_mul(2).min(width.saturating_sub(1)); + let y0 = y.saturating_mul(2).min(height.saturating_sub(1)); + let x1 = x0.saturating_add(1).min(width.saturating_sub(1)); + let y1 = y0.saturating_add(1).min(height.saturating_sub(1)); + let source_width = usize::try_from(width).unwrap_or(0); + let samples = [ + (usize::try_from(y0).unwrap_or(0) * source_width + + usize::try_from(x0).unwrap_or(0)) + * 4, + (usize::try_from(y0).unwrap_or(0) * source_width + + usize::try_from(x1).unwrap_or(0)) + * 4, + (usize::try_from(y1).unwrap_or(0) * source_width + + usize::try_from(x0).unwrap_or(0)) + * 4, + (usize::try_from(y1).unwrap_or(0) * source_width + + usize::try_from(x1).unwrap_or(0)) + * 4, + ]; + for channel in 0..4 { + let sum: u16 = samples + .iter() + .map(|&offset| u16::from(level[offset + channel])) + .sum(); + next[dst + channel] = u8::try_from((sum + 2) / 4).unwrap_or(u8::MAX); + } + } + } + bytes.extend_from_slice(&next); + level = next; + width = next_width; + height = next_height; + mip_levels = mip_levels.saturating_add(1); + } + (bytes, mip_levels) +} + pub(super) fn destroy_allocated_image( device: &VulkanLogicalDeviceProbe, image: &VulkanAllocatedImage, @@ -624,36 +1220,24 @@ pub(super) fn create_frame_sync( let mut sync = Vec::with_capacity(2); for _ in 0..2 { // SAFETY: The sync objects belong to this live logical device and are destroyed at teardown. - let image_available = unsafe { device.device().create_semaphore(&semaphore_info, None) } - .map_err(|error| VulkanSmokeRendererError::VulkanOperation { - context: "vkCreateSemaphore(image_available)", - result: error, - })?; - let render_finished = { - // SAFETY: The sync objects belong to this live logical device and are destroyed at teardown. + let image_available = match unsafe { device.device().create_semaphore(&semaphore_info, None) } { - Ok(render_finished) => render_finished, + Ok(image_available) => image_available, Err(error) => { destroy_frame_sync_objects(device, &sync); - // SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure. - unsafe { device.device().destroy_semaphore(image_available, None) }; return Err(VulkanSmokeRendererError::VulkanOperation { - context: "vkCreateSemaphore(render_finished)", + context: "vkCreateSemaphore(image_available)", result: error, }); } - } - }; + }; // SAFETY: The fence belongs to this live logical device and is destroyed at teardown. let fence = match unsafe { device.device().create_fence(&fence_info, None) } { Ok(fence) => fence, Err(error) => { destroy_frame_sync_objects(device, &sync); - // SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure. - unsafe { - device.device().destroy_semaphore(image_available, None); - device.device().destroy_semaphore(render_finished, None); - } + // SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure. + unsafe { device.device().destroy_semaphore(image_available, None) }; return Err(VulkanSmokeRendererError::VulkanOperation { context: "vkCreateFence", result: error, @@ -662,7 +1246,6 @@ pub(super) fn create_frame_sync( }; sync.push(VulkanFrameSync { image_available, - render_finished, fence, }); } @@ -676,9 +1259,6 @@ fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanF device .device() .destroy_semaphore(frame_sync.image_available, None); - device - .device() - .destroy_semaphore(frame_sync.render_finished, None); device.device().destroy_fence(frame_sync.fence, None); } } @@ -738,11 +1318,11 @@ pub(super) fn readback_buffer_bytes( Ok(bytes) } -pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange { +pub(super) fn color_subresource_range(level_count: u32) -> vk::ImageSubresourceRange { vk::ImageSubresourceRange::default() .aspect_mask(vk::ImageAspectFlags::COLOR) .base_mip_level(0) - .level_count(1) + .level_count(level_count) .base_array_layer(0) .layer_count(1) } diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index e2f6c34..e8e18ac 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -1,15 +1,19 @@ #![allow(unsafe_code)] use ash::vk; +use fparkan_render::LegacyDepthMode; use super::smoke_types::resolve_draw_texture_indices; +use super::update_material_texture_descriptor; use super::{ - create_command_pool, create_frame_sync, create_static_mesh_index_buffer, + create_command_pool, create_frame_sync, create_index_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources, - create_validation_messenger, create_vulkan_instance_probe, + create_validation_messenger, create_vertex_buffer, create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image, - destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, VulkanAllocatedBuffer, + destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, + update_static_texture_image, write_frame_uniform, write_material_specular_buffer, + write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer, VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, @@ -185,38 +189,225 @@ impl VulkanSmokeRenderer { return Err(error); } }; - // Keep the compatibility triangle path valid: it samples a white texel - // when no original TEXM was supplied. + // Keep the compatibility triangle path valid: a white base texel, + // neutral detail texels and a transparent white overlay make the + // additional terrain stages mathematically transparent. let fallback_texture = super::VulkanStaticTexture { width: 1, height: 1, rgba8: vec![255, 255, 255, 255], + mip_levels: Vec::new(), }; + let fallback_detail_texture = super::VulkanStaticTexture { + width: 1, + height: 1, + rgba8: vec![128, 128, 128, 255], + mip_levels: Vec::new(), + }; + let fallback_overlay_texture = super::VulkanStaticTexture { + width: 1, + height: 1, + 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. let texture_sources = if create_info.materials.is_empty() { - vec![&fallback_texture] + vec![ + (&fallback_texture, Some(0_usize)), + (&fallback_detail_texture, Some(1_usize)), + (&fallback_overlay_texture, Some(2_usize)), + (&fallback_detail_texture, Some(1_usize)), + ] + } 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(( + material + .detail_texture + .as_ref() + .map_or(&fallback_detail_texture, |texture| texture), + material.detail_texture.is_none().then_some(1_usize), + )); + 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(( + material + .overlay_detail_texture + .as_ref() + .map_or(&fallback_detail_texture, |texture| texture), + material.overlay_detail_texture.is_none().then_some(1_usize), + )); + } + 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, + ) { + 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); + texture_index + }; + texture_slot_indices.push(texture_index); + } + let material_alphas = if create_info.materials.is_empty() { + vec![[1.0, 0.0]] } else { create_info .materials .iter() - .map(|material| &material.texture) + .map(|material| { + [ + material.diffuse_alpha, + material.overlay_diffuse_alpha.unwrap_or(0.0), + ] + }) + .collect() + }; + let material_lighting = if create_info.materials.is_empty() { + vec![[[1.0; 3], [0.0; 3], [1.0; 3], [0.0; 3]]] + } else { + create_info + .materials + .iter() + .map(|material| { + [ + material.directional_rgb, + material.additive_rgb, + material.overlay_directional_rgb.unwrap_or([1.0; 3]), + material.overlay_additive_rgb.unwrap_or([0.0; 3]), + ] + }) + .collect() + }; + let material_uv_transforms = if create_info.materials.is_empty() { + vec![[[0.0, 0.0, 1.0, 1.0]; 4]] + } else { + create_info + .materials + .iter() + .map(|material| { + [ + material.uv_transform, + material.detail_uv_transform, + material + .overlay_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]), + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]), + ] + }) + .collect() + }; + let material_unlit = if create_info.materials.is_empty() { + vec![0.0] + } else { + create_info + .materials + .iter() + .map(|material| { + let combiner_mode = if material.lightmap_mode { + 3.0 + } else if material.sky_nebula_stars { + 2.0 + } else if material.unlit { + 1.0 + } else { + 0.0 + }; + combiner_mode + if material.sky_far_depth { 4.0 } else { 0.0 } + }) + .collect() + }; + let material_specular = if create_info.materials.is_empty() { + vec![[[0.0; 4]; 2]] + } else { + create_info + .materials + .iter() + .map(|material| { + [ + [ + material.specular_rgb[0], + material.specular_rgb[1], + material.specular_rgb[2], + f32::from(material.specular_power), + ], + [ + material.overlay_specular_rgb.unwrap_or([0.0; 3])[0], + material.overlay_specular_rgb.unwrap_or([0.0; 3])[1], + material.overlay_specular_rgb.unwrap_or([0.0; 3])[2], + f32::from(material.overlay_specular_power.unwrap_or(0)), + ], + ] + }) + .collect() + }; + let material_selectors = if create_info.materials.is_empty() { + vec![0] + } else { + create_info + .materials + .iter() + .map(|material| material.material_index) .collect() }; - let mut textures = Vec::with_capacity(texture_sources.len()); - for texture_source in texture_sources { - match create_static_texture_image(&instance, &device, command_pool, texture_source) { - Ok(image) => textures.push(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 mut renderer = Self { instance: Some(instance), validation, @@ -227,10 +418,38 @@ impl VulkanSmokeRenderer { swapchain_resources: None, vertex_buffer: Some(vertex_buffer), index_buffer: Some(index_buffer), + texture_slot_indices, textures, + vertex_count: create_info.mesh.vertices.len(), + index_capacity: create_info.mesh.indices.len(), + draw_range_capacities: create_info + .mesh + .draw_ranges + .iter() + .map(|range| range.index_count) + .collect(), draw_ranges: create_info.mesh.draw_ranges.clone(), draw_texture_indices, + draw_order: (0..create_info.mesh.draw_ranges.len()).collect(), + draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()], + draw_order_dirty: false, + draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()], + draw_range_base_pipeline_states: create_info + .mesh + .draw_ranges + .iter() + .map(|range| range.pipeline_state) + .collect(), + material_alphas, + material_lighting, + material_uv_transforms, + material_unlit, + material_specular, + material_selectors, + material_specular_dirty: true, + readback_enabled: false, camera: create_info.camera, + frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera), frame_sync: Vec::new(), images_in_flight: Vec::new(), current_frame: 0, @@ -318,6 +537,12 @@ impl VulkanSmokeRenderer { self.camera } + /// Returns the frame state uploaded before the next recorded frame. + #[must_use] + pub const fn frame_uniforms(&self) -> super::VulkanFrameUniforms { + self.frame_uniforms + } + /// Replaces the camera for subsequent frames without recreating GPU resources. /// /// # Errors @@ -335,6 +560,585 @@ impl VulkanSmokeRenderer { }); } self.camera = camera; + self.frame_uniforms.clip_from_world = camera.clip_from_world; + Ok(()) + } + + /// Replaces lighting, fog, camera-position and matrix state for + /// subsequent frames without recreating GPU resources. + /// + /// # Errors + /// + /// Returns [`VulkanSmokeRendererError::InvalidStaticCamera`] when any + /// submitted value is non-finite. The caller must update only between + /// [`Self::draw_frame`] calls on the renderer-owning thread. + pub fn set_frame_uniforms( + &mut self, + frame_uniforms: super::VulkanFrameUniforms, + ) -> Result<(), VulkanSmokeRendererError> { + if !frame_uniforms.is_finite() { + return Err(VulkanSmokeRendererError::InvalidStaticCamera { + context: "non-finite frame uniform", + }); + } + self.camera.clip_from_world = frame_uniforms.clip_from_world; + self.frame_uniforms = frame_uniforms; + Ok(()) + } + + /// Enables the optional swapchain image copy used by screenshot/readback + /// callers. Runtime frames leave it disabled to avoid a transfer every + /// frame. + pub fn set_readback_enabled(&mut self, enabled: bool) { + self.readback_enabled = enabled; + if !enabled { + self.last_readback_image_index = None; + } + } + + /// Replaces the shared vertex allocation between frames. + /// + /// 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 + /// no submitted command can observe a partial update. + pub fn update_vertices( + &mut self, + vertices: &[super::VulkanStaticVertex], + ) -> Result<(), VulkanSmokeRendererError> { + if vertices.len() != self.vertex_count { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + 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) + } + + /// Uploads the current shared mesh while retaining dynamic draw state. + /// + /// Environment geometry and projected shadow geometry share the original + /// vertex/index allocations. `geometry_ranges` carries only the ranges + /// whose index segment changed; material selection, blend/depth state, + /// 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. + /// + /// 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 + /// Vulkan never receives a zero-sized allocation. + pub fn update_mesh_geometry( + &mut self, + vertices: &[super::VulkanStaticVertex], + indices: &[u32], + geometry_ranges: &[super::VulkanDynamicDrawRange], + ) -> Result<(), VulkanSmokeRendererError> { + validate_geometry_upload( + vertices, + indices, + &self.draw_ranges, + geometry_ranges, + self.vertex_count, + 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, + } + })?; + + let (new_vertex_buffer, new_index_buffer) = if vertex_grew || index_grew { + let instance = self.instance_ref()?; + let device = self.device_ref()?; + let new_vertex_buffer = if vertex_grew { + Some(create_vertex_buffer(instance, device, vertices)?) + } else { + None + }; + let new_index_buffer = if index_grew { + match create_index_buffer(instance, device, indices) { + Ok(buffer) => Some(buffer), + Err(error) => { + if let Some(buffer) = new_vertex_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + return Err(error); + } + } + } else { + None + }; + if !vertex_grew { + if let Err(error) = + write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices) + { + if let Some(buffer) = new_vertex_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + if let Some(buffer) = new_index_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + return Err(error); + } + } + if !index_grew { + if let Err(error) = + write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices) + { + if let Some(buffer) = new_vertex_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + if let Some(buffer) = new_index_buffer.as_ref() { + destroy_allocated_buffer(device, buffer); + } + return Err(error); + } + } + (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)?; + (None, None) + }; + + if let Some(buffer) = new_vertex_buffer { + let old = self.vertex_buffer.replace(buffer); + if let Some(old) = old { + destroy_allocated_buffer(self.device_ref()?, &old); + } + self.vertex_count = vertices.len(); + } + if let Some(buffer) = new_index_buffer { + let old = self.index_buffer.replace(buffer); + if let Some(old) = old { + destroy_allocated_buffer(self.device_ref()?, &old); + } + self.index_capacity = indices.len(); + } + + apply_geometry_range_updates( + &mut self.draw_ranges, + &mut self.draw_range_capacities, + geometry_ranges, + ); + Ok(()) + } + + /// Replaces one material texture in place while retaining its descriptor + /// set and sampler. This is used by dynamic fixed-size atlases such as + /// the native 256 by 256 projected-shadow texture. + /// + /// 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 + /// this between frame updates without taking ownership of Vulkan handles. + pub fn update_material_texture( + &mut self, + material_index: usize, + texture_slot: usize, + texture: &super::VulkanStaticTexture, + ) -> Result<(), VulkanSmokeRendererError> { + if texture_slot >= 4 { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture slot is out of bounds", + }); + } + let logical_texture_index = material_index + .checked_mul(4) + .and_then(|index| index.checked_add(texture_slot)) + .ok_or(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture index overflows address space", + })?; + let texture_index = self + .texture_slot_indices + .get(logical_texture_index) + .copied() + .ok_or(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture index is out of bounds", + })?; + if self.textures.get(texture_index).is_none() { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + 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, + } + })?; + } + let shared_texture = self + .texture_slot_indices + .iter() + .filter(|mapped_index| **mapped_index == texture_index) + .count() + > 1; + if shared_texture { + // A fallback 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 = { + let instance = self.instance_ref()?; + let device = self.device_ref()?; + create_static_texture_image(instance, device, self.command_pool, texture)? + }; + let new_texture_index = self.textures.len(); + self.textures.push(image); + self.texture_slot_indices[logical_texture_index] = new_texture_index; + let device = self.device_ref()?; + let resources = self.resources_ref()?; + update_material_texture_descriptor( + device, + resources, + material_index, + texture_slot, + &self.textures[new_texture_index], + ) + } else { + let instance = self.instance_ref()?; + let device = self.device_ref()?; + let image = self.textures.get(texture_index).ok_or( + VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture index is out of bounds", + }, + )?; + update_static_texture_image(instance, device, self.command_pool, image, texture) + } + } + + /// Changes one draw range's active index count while retaining its + /// pipeline and material selector. This lets a fixed-capacity weather + /// allocation draw only the particles emitted in the current frame. + pub fn set_draw_range_index_count( + &mut self, + range_index: usize, + index_count: u32, + ) -> Result<(), VulkanSmokeRendererError> { + if index_count % 3 != 0 { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index count is not a triangle count", + }); + } + let capacity = self.draw_range_capacities.get(range_index).copied().ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + if index_count > capacity { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range exceeds its index allocation", + }); + } + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + range.index_count = index_count; + Ok(()) + } + + /// Sets the native per-range alpha reference in legacy 0..=255 units. + /// + /// A value of zero selects the renderer's proven native default (one) for + /// alpha-test ranges. The update is dynamic and does not recreate a + /// graphics pipeline. + pub fn set_draw_range_alpha_test_reference( + &mut self, + range_index: usize, + alpha_test_reference: u8, + ) -> Result<(), VulkanSmokeRendererError> { + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + range.alpha_test_reference = alpha_test_reference; + Ok(()) + } + + /// Marks a draw range as belonging to the native transparent queue. + /// + /// The flag is deliberately independent of the blend category: native + /// materials can blend while retaining depth writes, and some alpha + /// materials are depth-tested opaque. When enabled, a range that uses a + /// depth attachment switches to depth-test/read-only. The original depth + /// state is retained so disabling the flag restores the caller's state. + /// + /// The transparent flag only participates in the world draw order. The + /// backend keeps every unmarked range in its original slot, which leaves + /// the fixed screen/sky/weather pass order intact. + pub fn set_draw_range_transparency( + &mut self, + range_index: usize, + transparent: bool, + ) -> Result<(), VulkanSmokeRendererError> { + let base_state = *self + .draw_range_base_pipeline_states + .get(range_index) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + })?; + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + let depth = if transparent && base_state.depth != LegacyDepthMode::Disabled { + LegacyDepthMode::TestReadOnly + } else { + base_state.depth + }; + let mut next_state = base_state; + next_state.depth = depth; + 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 + // intentionally owned by the caller. + self.draw_range_world_queue[range_index] = Some(transparent); + self.draw_order_dirty = true; + if pipeline_changed { + self.rebuild_pipeline_resources()?; + } + Ok(()) + } + + /// Supplies the native camera-sort key returned by the draw-item camera + /// callback. The native callback computes Euclidean distance from the + /// camera to the first world vertex of the whole indexed batch. + /// + /// The recovered comparator inserts in descending floating-point camera + /// key order. Keeping the key separate from the range handle mirrors the + /// native layer's callback result and its `[item pointer, key]` entries. + pub fn set_draw_range_sort_key( + &mut self, + range_index: usize, + sort_key: f32, + ) -> Result<(), VulkanSmokeRendererError> { + if !sort_key.is_finite() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "native draw sort key must be finite", + }); + } + let key = self.draw_range_sort_keys.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + *key = Some(sort_key); + self.draw_order_dirty = true; + Ok(()) + } + + /// Returns the current stable range-handle order used by command + /// recording. Handles are indices into the original `draw_ranges` vector. + #[must_use] + pub fn draw_order(&self) -> &[usize] { + &self.draw_order + } + + /// Changes the diffuse and overlay alpha values carried by one material's + /// push constants. The descriptor images remain unchanged. + pub fn set_material_alphas( + &mut self, + material_index: usize, + diffuse_alpha: f32, + overlay_alpha: f32, + ) -> Result<(), VulkanSmokeRendererError> { + if !diffuse_alpha.is_finite() + || !overlay_alpha.is_finite() + || !(0.0..=1.0).contains(&diffuse_alpha) + || !(0.0..=1.0).contains(&overlay_alpha) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material alpha must be finite and 0..1", + }); + } + let alphas = self.material_alphas.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + *alphas = [diffuse_alpha, overlay_alpha]; + Ok(()) + } + + /// Changes the base directional and additive lighting coefficients of one + /// material while retaining its descriptor images and pipeline state. + pub fn set_material_lighting( + &mut self, + material_index: usize, + directional_rgb: [f32; 3], + additive_rgb: [f32; 3], + ) -> Result<(), VulkanSmokeRendererError> { + self.set_material_lighting_with_overlay( + material_index, + directional_rgb, + additive_rgb, + None, + None, + ) + } + + /// Changes base and optional overlay directional/additive coefficients + /// while retaining descriptor images and pipeline state. + pub fn set_material_lighting_with_overlay( + &mut self, + material_index: usize, + directional_rgb: [f32; 3], + additive_rgb: [f32; 3], + overlay_directional_rgb: Option<[f32; 3]>, + overlay_additive_rgb: Option<[f32; 3]>, + ) -> Result<(), VulkanSmokeRendererError> { + if directional_rgb + .into_iter() + .chain(additive_rgb) + .chain(overlay_directional_rgb.into_iter().flatten()) + .chain(overlay_additive_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material lighting must be finite and non-negative", + }); + } + let lighting = self.material_lighting.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + lighting[0] = directional_rgb; + lighting[1] = additive_rgb; + lighting[2] = overlay_directional_rgb.unwrap_or([1.0; 3]); + lighting[3] = overlay_additive_rgb.unwrap_or([0.0; 3]); + Ok(()) + } + + /// Replaces the four Page transforms carried by one material's push + /// constants. The transform order is base, detail, overlay, and overlay + /// detail, matching the material shader's texture bindings. + /// + /// The update is kept CPU-side and is consumed by the next recorded draw; + /// no descriptor or image resource is recreated. This is intentionally a + /// complete replacement so an animated MAT0 phase cannot leave one stale + /// Page rectangle behind when its texture set is unchanged. + pub fn set_material_uv_transforms( + &mut self, + material_index: usize, + uv_transforms: [[f32; 4]; 4], + ) -> Result<(), VulkanSmokeRendererError> { + if uv_transforms + .into_iter() + .flatten() + .any(|value| !value.is_finite()) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material UV transform must be finite", + }); + } + let transforms = self.material_uv_transforms.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + *transforms = uv_transforms; + Ok(()) + } + + /// Replaces the base and overlay specular records used by the vertex + /// Gouraud lighting path. RGB values remain normalized; powers retain + /// the native MAT0 byte and are encoded as the fourth float in each GPU + /// record. + pub fn set_material_specular( + &mut self, + material_index: usize, + specular_rgb: [f32; 3], + power: u8, + overlay_specular_rgb: Option<[f32; 3]>, + overlay_power: Option, + ) -> Result<(), VulkanSmokeRendererError> { + if specular_rgb + .into_iter() + .chain(overlay_specular_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material specular RGB must be finite and non-negative", + }); + } + let specular = self.material_specular.get_mut(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + let overlay_rgb = overlay_specular_rgb.unwrap_or([0.0; 3]); + let next = [ + [ + specular_rgb[0], + specular_rgb[1], + specular_rgb[2], + f32::from(power), + ], + [ + overlay_rgb[0], + overlay_rgb[1], + overlay_rgb[2], + f32::from(overlay_power.unwrap_or(0)), + ], + ]; + if *specular == next { + return Ok(()); + } + *specular = next; + self.material_specular_dirty = true; + Ok(()) + } + + /// Rebinds one draw range to a positional material slot without + /// reallocating any vertex or descriptor resources. + pub fn set_draw_range_material( + &mut self, + range_index: usize, + material_index: usize, + ) -> Result<(), VulkanSmokeRendererError> { + let selector = *self.material_selectors.get(material_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic material index is out of bounds", + }, + )?; + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + range.material_index = selector; + let texture_index = self.draw_texture_indices.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + *texture_index = material_index; Ok(()) } @@ -434,9 +1238,12 @@ impl VulkanSmokeRenderer { return Ok(VulkanSmokeFrameOutcome::Recreated); } - let sync = &self.frame_sync[self.current_frame]; + let sync = self.frame_sync.get(self.current_frame).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "frame synchronization state", + }, + )?; let image_available = sync.image_available; - let render_finished = sync.render_finished; let in_flight_fence = sync.fence; // SAFETY: The fence belongs to this live logical device and is waited from one thread. unsafe { @@ -448,6 +1255,27 @@ impl VulkanSmokeRenderer { context: "vkWaitForFences", result: error, })?; + if self.material_specular_dirty { + // The storage buffer is shared by all in-flight frames. Wait for + // every prior use before replacing the records so a setter cannot + // 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, + } + })?; + let resources = self.resources_ref()?; + write_material_specular_buffer( + self.device_ref()?, + &resources.material_specular_buffer, + resources.material_specular_stride, + &self.material_specular, + )?; + self.material_specular_dirty = false; + } // SAFETY: The swapchain, semaphore and fence inputs are live for the duration of the acquire call. let acquire = unsafe { self.swapchain_ref()?.loader().acquire_next_image( @@ -485,6 +1313,26 @@ impl VulkanSmokeRenderer { })?; } self.images_in_flight[image_index_usize] = in_flight_fence; + let frame_bytes = self.frame_uniforms.to_ne_bytes(); + let resources = self.resources_ref()?; + write_frame_uniform( + self.device_ref()?, + &resources.frame_uniform_buffer, + resources.frame_uniform_stride, + self.current_frame, + &frame_bytes, + )?; + let render_finished = self + .resources_ref()? + .render_finished + .get(image_index_usize) + .copied() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "render-finished semaphore for swapchain image", + })?; + self.record_command_buffer(image_index_usize)?; + // Reset only after recording succeeds. A recording error leaves the + // signaled fence available for the next recovery attempt. // SAFETY: The fence belongs to this frame context and is not in use after the wait above. unsafe { self.device_ref()?.device().reset_fences(&[in_flight_fence]) }.map_err( |error| VulkanSmokeRendererError::VulkanOperation { @@ -492,8 +1340,6 @@ impl VulkanSmokeRenderer { result: error, }, )?; - - self.record_command_buffer(image_index_usize)?; let wait_semaphores = [image_available]; let wait_stages = [vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT]; let command_buffers = [self.resources_ref()?.command_buffers[image_index_usize]]; @@ -515,7 +1361,7 @@ impl VulkanSmokeRenderer { context: "vkQueueSubmit", result: error, })?; - if !self.resources_ref()?.readback_buffers.is_empty() { + if self.readback_enabled && !self.resources_ref()?.readback_buffers.is_empty() { self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1); self.last_readback_image_index = Some(image_index_usize); } @@ -607,6 +1453,7 @@ impl VulkanSmokeRenderer { self.vertex_buffer_ref()?, self.index_buffer_ref()?, self.textures_ref()?, + &self.texture_slot_indices, &self.draw_ranges, self.depth_request, reuse_command_pool, @@ -621,6 +1468,7 @@ impl VulkanSmokeRenderer { self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()]; self.frame_sync = frame_sync; self.last_readback_image_index = None; + self.material_specular_dirty = true; self.report.swapchain_extent = swapchain_extent; self.report.swapchain_image_count = swapchain_image_count; self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format; @@ -629,10 +1477,54 @@ impl VulkanSmokeRenderer { 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. Only ranges explicitly + /// marked transparent are reordered, and they remain in the slots occupied + /// by that queue. This preserves the caller's fixed sky/screen/weather + /// pass order while matching the native descending key comparator for the + /// world transparent queue. + fn rebuild_draw_order(&mut self) { + let range_count = self.draw_ranges.len(); + if self.draw_order.len() != range_count { + self.draw_order = (0..range_count).collect(); + } + self.draw_order = sorted_world_draw_order( + range_count, + &self.draw_ranges, + &self.draw_range_world_queue, + &self.draw_range_sort_keys, + ); + } + fn record_command_buffer( &mut self, image_index: usize, ) -> Result<(), VulkanSmokeRendererError> { + if self.draw_order_dirty { + self.rebuild_draw_order(); + self.draw_order_dirty = false; + } let device = self.device_ref()?; let swapchain = self.swapchain_ref()?; let resources = self.resources_ref()?; @@ -702,15 +1594,17 @@ impl VulkanSmokeRenderer { 0, vk::IndexType::UINT32, ); - let clip_from_world = matrix_bytes(self.camera.clip_from_world); - device.device().cmd_push_constants( - command_buffer, - resources.pipeline_layout, - vk::ShaderStageFlags::VERTEX, - 0, - &clip_from_world, - ); - for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) { + for &range_index in &self.draw_order { + let range = self.draw_ranges.get(range_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "stable draw order range handle", + }, + )?; + let texture_index = *self.draw_texture_indices.get(range_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "stable draw texture range handle", + }, + )?; let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or( VulkanSmokeRendererError::InvariantViolation { context: "static draw pipeline variant", @@ -721,6 +1615,18 @@ impl VulkanSmokeRenderer { vk::PipelineBindPoint::GRAPHICS, *pipeline, ); + let frame_offset = u32::try_from( + resources + .frame_uniform_stride + .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)), + ) + .unwrap_or(u32::MAX); + let material_offset = u32::try_from( + resources + .material_specular_stride + .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)), + ) + .unwrap_or(u32::MAX); let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or( VulkanSmokeRendererError::InvariantViolation { context: "static material descriptor set", @@ -732,16 +1638,80 @@ impl VulkanSmokeRenderer { resources.pipeline_layout, 0, std::slice::from_ref(descriptor_set), - &[], + &[frame_offset, material_offset], ); let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); device.device().cmd_push_constants( command_buffer, resources.pipeline_layout, - vk::ShaderStageFlags::FRAGMENT, - 64, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 0, &alpha_cutoff, ); + let material_alpha = self.material_alphas.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material alpha", + }, + )?; + let material_alphas = [ + material_alpha[0].to_ne_bytes(), + material_alpha[1].to_ne_bytes(), + ] + .concat(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 4, + &material_alphas, + ); + let material_lighting = self.material_lighting.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material lighting", + }, + )?; + let material_lighting = material_lighting + .iter() + .flatten() + .flat_map(|value| value.to_ne_bytes()) + .collect::>(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 12, + &material_lighting, + ); + let material_uv_transforms = self.material_uv_transforms.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material UV transforms", + }, + )?; + let material_uv_transforms = material_uv_transforms + .iter() + .flatten() + .flat_map(|value| value.to_ne_bytes()) + .collect::>(); + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 60, + &material_uv_transforms, + ); + let unlit = self.material_unlit.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material unlit flag", + }, + )?; + let unlit = [unlit.to_ne_bytes()]; + device.device().cmd_push_constants( + command_buffer, + resources.pipeline_layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 124, + &unlit.concat(), + ); device.device().cmd_draw_indexed( command_buffer, range.index_count, @@ -752,63 +1722,65 @@ impl VulkanSmokeRenderer { ); } device.device().cmd_end_render_pass(command_buffer); - if let (Some(image), Some(readback)) = ( - resources.images.get(image_index), - resources.readback_buffers.get(image_index), - ) { - let range = super::color_subresource_range(); - let to_transfer = vk::ImageMemoryBarrier::default() - .old_layout(vk::ImageLayout::PRESENT_SRC_KHR) - .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) - .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) - .dst_access_mask(vk::AccessFlags::TRANSFER_READ) - .image(*image) - .subresource_range(range); - let back_to_present = vk::ImageMemoryBarrier::default() - .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) - .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) - .src_access_mask(vk::AccessFlags::TRANSFER_READ) - .dst_access_mask(vk::AccessFlags::empty()) - .image(*image) - .subresource_range(range); - let region = vk::BufferImageCopy::default() - .image_subresource( - vk::ImageSubresourceLayers::default() - .aspect_mask(vk::ImageAspectFlags::COLOR) - .mip_level(0) - .base_array_layer(0) - .layer_count(1), - ) - .image_extent(vk::Extent3D { - width: swapchain.report.plan.extent.0, - height: swapchain.report.plan.extent.1, - depth: 1, - }); - device.device().cmd_pipeline_barrier( - command_buffer, - vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, - vk::PipelineStageFlags::TRANSFER, - vk::DependencyFlags::empty(), - &[], - &[], - &[to_transfer], - ); - device.device().cmd_copy_image_to_buffer( - command_buffer, - *image, - vk::ImageLayout::TRANSFER_SRC_OPTIMAL, - readback.buffer, - &[region], - ); - device.device().cmd_pipeline_barrier( - command_buffer, - vk::PipelineStageFlags::TRANSFER, - vk::PipelineStageFlags::BOTTOM_OF_PIPE, - vk::DependencyFlags::empty(), - &[], - &[], - &[back_to_present], - ); + if self.readback_enabled { + if let (Some(image), Some(readback)) = ( + resources.images.get(image_index), + resources.readback_buffers.get(image_index), + ) { + let range = super::color_subresource_range(1); + let to_transfer = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .dst_access_mask(vk::AccessFlags::TRANSFER_READ) + .image(*image) + .subresource_range(range); + let back_to_present = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .src_access_mask(vk::AccessFlags::TRANSFER_READ) + .dst_access_mask(vk::AccessFlags::empty()) + .image(*image) + .subresource_range(range); + let region = vk::BufferImageCopy::default() + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(0) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width: swapchain.report.plan.extent.0, + height: swapchain.report.plan.extent.1, + depth: 1, + }); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_transfer], + ); + device.device().cmd_copy_image_to_buffer( + command_buffer, + *image, + vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + readback.buffer, + &[region], + ); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + vk::DependencyFlags::empty(), + &[], + &[], + &[back_to_present], + ); + } } } @@ -832,9 +1804,6 @@ impl VulkanSmokeRenderer { device .device() .destroy_semaphore(sync.image_available, None); - device - .device() - .destroy_semaphore(sync.render_finished, None); device.device().destroy_fence(sync.fence, None); } } @@ -937,7 +1906,7 @@ impl VulkanSmokeRenderer { .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { context: "completed readback byte length", })?; - if resources.readback_buffers.is_empty() { + if !self.readback_enabled || resources.readback_buffers.is_empty() { return Ok(None); } let Some(image_index) = completed_readback_buffer_index( @@ -986,14 +1955,6 @@ fn completed_readback_buffer_index( } } -fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] { - let mut bytes = [0; 64]; - for (index, value) in matrix.into_iter().enumerate() { - bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes()); - } - bytes -} - fn fnv1a64(bytes: &[u8]) -> u64 { bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) @@ -1006,12 +1967,242 @@ impl Drop for VulkanSmokeRenderer { } } +fn validate_geometry_upload( + vertices: &[super::VulkanStaticVertex], + indices: &[u32], + current_ranges: &[super::VulkanStaticDrawRange], + geometry_ranges: &[super::VulkanDynamicDrawRange], + retained_vertex_capacity: usize, + retained_index_capacity: usize, +) -> Result<(), VulkanSmokeRendererError> { + if vertices.is_empty() || indices.is_empty() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh upload cannot have zero-sized geometry", + }); + } + if !indices.len().is_multiple_of(3) { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh index upload is not a triangle list", + }); + } + if vertices.iter().any(|vertex| { + !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()) + || !(0.0..=1.0).contains(&vertex.overlay_alpha) + }) { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh vertex attributes must be finite and overlay alpha must be 0..1", + }); + } + if indices.iter().any(|&index| { + usize::try_from(index) + .ok() + .is_none_or(|index| index >= vertices.len()) + }) { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh index exceeds vertex count", + }); + } + if retained_vertex_capacity == 0 || retained_index_capacity == 0 { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "dynamic mesh retained allocation is empty", + }); + } + + let mut next_ranges = current_ranges.to_vec(); + let mut changed = vec![false; current_ranges.len()]; + for geometry in geometry_ranges { + if geometry.range_index >= current_ranges.len() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range index is out of bounds", + }); + } + if changed[geometry.range_index] { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range is updated more than once", + }); + } + changed[geometry.range_index] = true; + if geometry.first_index % 3 != 0 || geometry.index_count % 3 != 0 { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range is not triangle aligned", + }); + } + let first = usize::try_from(geometry.first_index).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range first index exceeds address space", + } + })?; + let count = usize::try_from(geometry.index_count).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range index count exceeds address space", + } + })?; + let end = first + .checked_add(count) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range exceeds address space", + })?; + if end > indices.len() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic mesh range exceeds uploaded indices", + }); + } + next_ranges[geometry.range_index].first_index = geometry.first_index; + next_ranges[geometry.range_index].index_count = geometry.index_count; + } + + for range in next_ranges { + if range.first_index % 3 != 0 || range.index_count % 3 != 0 { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range is not triangle aligned", + }); + } + let first = usize::try_from(range.first_index).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range first index exceeds address space", + } + })?; + let count = usize::try_from(range.index_count).map_err(|_| { + VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range index count exceeds address space", + } + })?; + let end = first + .checked_add(count) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range exceeds address space", + })?; + if end > indices.len() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "retained dynamic mesh range exceeds uploaded indices", + }); + } + } + Ok(()) +} + +fn apply_geometry_range_updates( + ranges: &mut [super::VulkanStaticDrawRange], + capacities: &mut [u32], + geometry_ranges: &[super::VulkanDynamicDrawRange], +) { + for geometry in geometry_ranges { + // `validate_geometry_upload` has already checked every handle. Keep + // this helper infallible so state is changed only after the Vulkan + // upload succeeds. + let range = &mut ranges[geometry.range_index]; + range.first_index = geometry.first_index; + range.index_count = geometry.index_count; + // A moved segment no longer owns the old tail. Keeping the previous + // range capacity would let a later count setter read indices from a + // different segment. The shared GPU allocation itself remains + // retained; this is the logical capacity of this range's segment. + capacities[geometry.range_index] = geometry.index_count; + } +} + +/// Replays the native queue construction from the original stable range +/// order. Fixed passes stay in their original slots; world opaque ranges are +/// emitted first, followed by world transparent ranges inserted by descending +/// camera distance. Zero-index-count ranges are inactive and are omitted. +fn sorted_world_draw_order( + range_count: usize, + ranges: &[super::VulkanStaticDrawRange], + world_queue: &[Option], + keys: &[Option], +) -> Vec { + let mut next = (0..range_count).collect::>(); + let mut world_opaque = Vec::new(); + let mut world_transparent = Vec::new(); + for range_index in 0..range_count { + if ranges + .get(range_index) + .is_none_or(|range| range.index_count == 0) + { + continue; + } + match world_queue.get(range_index).copied().flatten() { + Some(false) => world_opaque.push(range_index), + Some(true) => world_transparent.push(range_index), + None => {} + } + } + + // Native equal-key insertion is a midpoint insertion rather than a + // documented stable sort. Replay that insertion shape from original range + // order instead of repeatedly sorting the previous frame's order. + let mut ordered_transparent = Vec::with_capacity(world_transparent.len()); + for range_index in world_transparent { + let new_key = keys.get(range_index).and_then(|key| *key); + let mut low = 0usize; + let mut high = ordered_transparent.len().saturating_sub(1); + while high > low { + let middle = low + (high - low) / 2; + let existing_key = keys.get(ordered_transparent[middle]).and_then(|key| *key); + match (new_key, existing_key) { + (Some(new_key), Some(existing_key)) if new_key > existing_key => high = middle, + (Some(new_key), Some(existing_key)) if new_key < existing_key => low = middle + 1, + (Some(_), Some(_)) => { + low = middle; + break; + } + (Some(_), None) => high = middle, + (None, Some(_)) => low = middle + 1, + (None, None) => { + low = middle; + break; + } + } + } + if !ordered_transparent.is_empty() && high == low { + let existing_key = keys.get(ordered_transparent[low]).and_then(|key| *key); + let insert_after = match (new_key, existing_key) { + (Some(new_key), Some(existing_key)) => new_key < existing_key, + (None, Some(_)) => true, + _ => false, + }; + if insert_after { + low += 1; + } + } + ordered_transparent.insert(low, range_index); + } + + let mut world_order = world_opaque; + world_order.extend(ordered_transparent); + let mut world_cursor = 0usize; + for slot in &mut next { + if world_queue.get(*slot).copied().flatten().is_some() + && ranges + .get(*slot) + .is_some_and(|range| range.index_count != 0) + { + if let Some(&replacement) = world_order.get(world_cursor) { + *slot = replacement; + world_cursor += 1; + } + } + } + next +} + #[cfg(test)] mod tests { + use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh}; use super::{ - completed_readback_buffer_index, take_runtime_children_with_validation_snapshot, - take_runtime_owners_in_dependency_order, RollbackOnDrop, + apply_geometry_range_updates, completed_readback_buffer_index, sorted_world_draw_order, + take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, + validate_geometry_upload, RollbackOnDrop, }; + use fparkan_render::{LegacyBlendMode, LegacyDepthMode, LegacyPipelineState}; use std::cell::RefCell; use std::rc::Rc; @@ -1100,6 +2291,84 @@ mod tests { assert!(completed_readback_buffer_index(Some(2), 2).is_err()); } + #[test] + fn geometry_updates_preserve_other_range_runtime_state() { + let base_state = LegacyPipelineState { + blend: LegacyBlendMode::SourceAlpha, + depth: LegacyDepthMode::TestReadOnly, + ..LegacyPipelineState::default() + }; + let mut ranges = vec![ + VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 7, + lightmap_index: 4, + batch_flags: 0x108, + pipeline_state: base_state, + alpha_test_reference: 17, + }, + VulkanStaticDrawRange { + first_index: 3, + index_count: 3, + material_index: 9, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + ]; + let original = ranges[0]; + let mut capacities = vec![3, 3]; + apply_geometry_range_updates( + &mut ranges, + &mut capacities, + &[VulkanDynamicDrawRange { + range_index: 1, + first_index: 3, + index_count: 9, + }], + ); + assert_eq!(ranges[0], original); + assert_eq!(capacities, vec![3, 9]); + assert_eq!(ranges[1].first_index, 3); + assert_eq!(ranges[1].index_count, 9); + + apply_geometry_range_updates( + &mut ranges, + &mut capacities, + &[VulkanDynamicDrawRange { + range_index: 1, + first_index: 3, + index_count: 3, + }], + ); + assert_eq!(ranges[0], original); + assert_eq!(capacities, vec![3, 3]); + assert_eq!(ranges[1].index_count, 3); + } + + #[test] + fn geometry_update_can_disable_empty_shadow_range_without_zero_sized_upload() { + let mesh = VulkanStaticMesh::smoke_triangle(); + let geometry = [VulkanDynamicDrawRange { + range_index: 0, + first_index: 3, + index_count: 0, + }]; + assert_eq!( + validate_geometry_upload( + &mesh.vertices, + &mesh.indices, + &mesh.draw_ranges, + &geometry, + 3, + 3, + ), + Ok(()) + ); + } + #[test] fn runtime_owners_drop_remaining_children_after_partial_init_failures() { let cases = [ @@ -1218,4 +2487,83 @@ mod tests { assert_eq!(log.borrow().as_slice(), &[TeardownStep::Swapchain]); } + + #[test] + fn native_transparent_order_is_descending_and_keeps_fixed_pass_slots() { + let ranges = (0..5) + .map(|_| super::super::VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }) + .collect::>(); + let order = sorted_world_draw_order( + 5, + &ranges, + &[None, Some(true), None, Some(true), Some(false)], + &[None, Some(2.0), None, Some(9.0), Some(-3.0)], + ); + assert_eq!(order, vec![0, 4, 2, 3, 1]); + } + + #[test] + fn inactive_world_range_is_not_reused_as_a_sort_slot() { + let ranges = vec![ + super::super::VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + super::super::VulkanStaticDrawRange { + first_index: 3, + index_count: 0, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + super::super::VulkanStaticDrawRange { + first_index: 3, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }, + ]; + let order = sorted_world_draw_order( + 3, + &ranges, + &[None, Some(true), Some(true)], + &[None, Some(100.0), Some(1.0)], + ); + assert_eq!(order, vec![0, 1, 2]); + } + + #[test] + fn native_equal_key_insertion_uses_inclusive_midpoint() { + let ranges = (0..4) + .map(|index| super::super::VulkanStaticDrawRange { + first_index: index * 3, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), + alpha_test_reference: 0, + }) + .collect::>(); + let order = sorted_world_draw_order(4, &ranges, &[Some(true); 4], &[Some(1.0); 4]); + assert_eq!(order, vec![2, 3, 1, 0]); + } } diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 217df3f..75877ad 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -48,8 +48,20 @@ pub struct VulkanStaticVertex { pub position: [f32; 3], /// Linear RGB vertex color. pub color: [f32; 3], + /// Unit vertex normal in world/object coordinates. + pub normal: [f32; 3], /// Texture coordinate consumed by the static material bridge. pub uv: [f32; 2], + /// Repeating detail texture coordinate from terrain type-18 UV data. + /// + /// Non-terrain geometry supplies `[0.0; 2]`; its neutral detail texture + /// keeps the additional stage mathematically transparent. + pub detail_uv: [f32; 2], + /// Interpolated terrain overlay mask from type-14 data. + /// + /// Non-terrain geometry supplies `0.0`, disabling the transparent overlay + /// slot while retaining one vertex contract for every static draw. + pub overlay_alpha: f32, } /// A static geometry camera represented in the shader's matrix memory order. @@ -71,7 +83,9 @@ impl VulkanStaticCamera { projection: LegacyD3d7Projection, ) -> Option { let view = transform.try_direct3d7_view_row_major()?; - let projection = projection.try_direct3d7_projection_row_major()?; + let mut projection = projection.try_direct3d7_projection_row_major()?; + // D3D7's top-down viewport and Vulkan's positive-height viewport invert NDC Y. + projection[5] = -projection[5]; Self::from_row_major_view_projection(view, projection) } @@ -108,6 +122,242 @@ impl Default for VulkanStaticCamera { } } +/// Number of directional records carried by [`VulkanFrameUniforms`]. +pub const VULKAN_DIRECTIONAL_LIGHT_COUNT: usize = 4; + +/// One directional light in the frame lighting contract. +/// +/// The direction is the vector from the light toward the surface. The +/// coefficients are retained in the same three-component record as the +/// native point callback so frame construction can copy the original light +/// table without a lossy conversion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanDirectionalLight { + /// Direction from the light toward the surface. + pub direction: [f32; 3], + /// Light RGB multiplier. + pub rgb: [f32; 3], + /// Native light coefficients. Directional callbacks normally use the + /// neutral `[0, 0, 1]` record; the field keeps the shared light ABI. + pub coefficients: [f32; 3], + /// Whether the light participates in this frame. + pub active: bool, +} + +impl Default for VulkanDirectionalLight { + fn default() -> Self { + Self { + direction: [0.0, 0.0, 1.0], + rgb: [0.0; 3], + coefficients: [0.0, 0.0, 1.0], + active: false, + } + } +} + +/// One point light in the frame lighting contract. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanPointLight { + /// World-space position. + pub position: [f32; 3], + /// Light RGB multiplier. + pub rgb: [f32; 3], + /// Radius used by native normalized distance attenuation. + pub range: f32, + /// Native attenuation coefficients `a0`, `a1`, and `a2`. + pub coefficients: [f32; 3], + /// Whether the light participates in this frame. + pub active: bool, +} + +impl Default for VulkanPointLight { + fn default() -> Self { + Self { + position: [0.0; 3], + rgb: [0.0; 3], + range: 0.0, + // A neutral record is explicit. In particular, this is the + // proven native environment contract and avoids an accidental + // zero polynomial when a caller enables the point slot. + coefficients: [0.0, 0.0, 1.0], + active: false, + } + } +} + +/// Number of bytes occupied by one frame-uniform slot. +/// +/// The block is deliberately made entirely from `mat4`/`vec4`-sized records +/// so its CPU representation follows Vulkan's `std140` alignment rules on +/// every device. Four directional lights and one point light fit in a small +/// dynamic UBO while the push-constant range remains available for material +/// state. +pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368; + +/// Bytes occupied by one material's base and overlay specular records. +/// +/// Each record is one `vec4`: RGB followed by the native integer power stored +/// as an IEEE-754 float for direct shader use. The allocation stride is +/// rounded up to the device's `minStorageBufferOffsetAlignment`. +pub const VULKAN_MATERIAL_SPECULAR_RECORD_BYTES: usize = 2 * 4 * std::mem::size_of::(); + +/// Per-frame data shared by the vertex and fragment stages. +/// +/// `clip_from_world` is row-major D3D-style storage for the same reason as +/// [`VulkanStaticCamera`]. A directional light direction points from the +/// light toward the surface; the shader negates it when taking the +/// surface-normal dot product. Fog distances are disabled when +/// `fog_end <= fog_start`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VulkanFrameUniforms { + /// Row-major clip-from-world transform. + pub clip_from_world: [f32; 16], + /// Up to four native directional/celestial lights. + pub directional_lights: [VulkanDirectionalLight; VULKAN_DIRECTIONAL_LIGHT_COUNT], + /// Native point light slot. + pub point_light: VulkanPointLight, + /// Per-channel lighting floor applied before native range compression. + pub lighting_floor: [f32; 3], + /// Linear fog RGB color. + pub fog_color: [f32; 3], + /// Fog start distance. + pub fog_start: f32, + /// Fog end distance. + pub fog_end: f32, + /// World-space camera position used for fog distance. + pub camera_position: [f32; 3], +} + +impl VulkanFrameUniforms { + /// Builds neutral frame state from a static camera. + #[must_use] + pub fn from_camera(camera: VulkanStaticCamera) -> Self { + let mut directional_lights = + [VulkanDirectionalLight::default(); VULKAN_DIRECTIONAL_LIGHT_COUNT]; + directional_lights[0] = VulkanDirectionalLight { + direction: [0.35, 0.45, 0.82], + rgb: [1.0; 3], + coefficients: [0.0, 0.0, 1.0], + active: true, + }; + Self { + clip_from_world: camera.clip_from_world, + directional_lights, + point_light: VulkanPointLight::default(), + lighting_floor: [0.30; 3], + fog_color: [0.0; 3], + fog_start: f32::MAX, + fog_end: f32::MAX, + camera_position: [0.0; 3], + } + } + + /// Returns whether all submitted frame values are finite. + #[must_use] + pub fn is_finite(self) -> bool { + self.clip_from_world + .into_iter() + .chain(self.directional_lights.into_iter().flat_map(|light| { + light + .direction + .into_iter() + .chain(light.rgb) + .chain(light.coefficients) + .chain([if light.active { 1.0 } else { 0.0 }]) + })) + .chain(self.point_light.position) + .chain(self.point_light.rgb) + .chain([self.point_light.range]) + .chain(self.point_light.coefficients) + .chain([if self.point_light.active { 1.0 } else { 0.0 }]) + .chain(self.lighting_floor) + .chain(self.fog_color) + .chain([self.fog_start, self.fog_end]) + .chain(self.camera_position) + .all(f32::is_finite) + } + + /// Serializes one frame slot in the exact `std140` member order. + #[must_use] + pub fn to_ne_bytes(self) -> [u8; VULKAN_FRAME_UNIFORM_BYTES] { + let mut values = [0.0_f32; VULKAN_FRAME_UNIFORM_BYTES / 4]; + values[..16].copy_from_slice(&self.clip_from_world); + for (index, light) in self.directional_lights.into_iter().enumerate() { + let direction_offset = 16 + index * 4; + values[direction_offset..direction_offset + 4].copy_from_slice(&[ + light.direction[0], + light.direction[1], + light.direction[2], + if light.active { 1.0 } else { 0.0 }, + ]); + let rgb_offset = 32 + index * 4; + values[rgb_offset..rgb_offset + 4].copy_from_slice(&[ + light.rgb[0], + light.rgb[1], + light.rgb[2], + if light.active { 1.0 } else { 0.0 }, + ]); + let coefficient_offset = 48 + index * 4; + values[coefficient_offset..coefficient_offset + 4].copy_from_slice(&[ + light.coefficients[0], + light.coefficients[1], + light.coefficients[2], + if light.active { 1.0 } else { 0.0 }, + ]); + } + values[64..68].copy_from_slice(&[ + self.point_light.position[0], + self.point_light.position[1], + self.point_light.position[2], + self.point_light.range, + ]); + values[68..72].copy_from_slice(&[ + self.point_light.rgb[0], + self.point_light.rgb[1], + self.point_light.rgb[2], + if self.point_light.active { 1.0 } else { 0.0 }, + ]); + values[72..76].copy_from_slice(&[ + self.point_light.coefficients[0], + self.point_light.coefficients[1], + self.point_light.coefficients[2], + if self.point_light.active { 1.0 } else { 0.0 }, + ]); + values[76..80].copy_from_slice(&[ + self.lighting_floor[0], + self.lighting_floor[1], + self.lighting_floor[2], + 0.0, + ]); + values[80..84].copy_from_slice(&[ + self.fog_color[0], + self.fog_color[1], + self.fog_color[2], + 0.0, + ]); + values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]); + values[88..92].copy_from_slice(&[ + self.camera_position[0], + self.camera_position[1], + self.camera_position[2], + 0.0, + ]); + let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES]; + for (index, value) in values.into_iter().enumerate() { + let offset = index * std::mem::size_of::(); + bytes[offset..offset + std::mem::size_of::()] + .copy_from_slice(&value.to_ne_bytes()); + } + bytes + } +} + +impl Default for VulkanFrameUniforms { + fn default() -> Self { + Self::from_camera(VulkanStaticCamera::default()) + } +} + fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { let mut result = [0.0; 16]; for row in 0..4 { @@ -131,6 +381,23 @@ pub struct VulkanStaticMesh { pub draw_ranges: Vec, } +/// Geometry-only changes applied to one retained draw range. +/// +/// The renderer owns the material, blend/depth state, active count changes, +/// and queue classification for every range. A frame builder uses this +/// record only when a variable-size geometry stream (such as projected +/// shadows) moves its index segment or changes its triangle count. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VulkanDynamicDrawRange { + /// Stable draw-range handle returned by the initial mesh layout. + pub range_index: usize, + /// First index in the current shared index upload. + pub first_index: u32, + /// Active triangle-list index count. Zero disables this range for the + /// current frame without allocating a zero-sized Vulkan buffer. + pub index_count: u32, +} + /// One indexed triangle-list draw retained from an original mesh batch. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct VulkanStaticDrawRange { @@ -140,6 +407,12 @@ pub struct VulkanStaticDrawRange { pub index_count: u32, /// Original positional `Batch20.material_index` selector. pub material_index: u16, + /// Original positional `Batch20.lightmap_index` selector. + pub lightmap_index: u8, + /// Original MSH Batch20 flag word used by the native transparent queue. + /// Bits `0x100` and `0x008` opt a range into that queue independently of + /// the material blend category. + pub batch_flags: u16, /// Backend-neutral fixed-function state for this source range. pub pipeline_state: LegacyPipelineState, /// Alpha-test reference in legacy 0..=255 units; dynamic material data. @@ -147,6 +420,13 @@ pub struct VulkanStaticDrawRange { } impl VulkanStaticDrawRange { + /// The value installed by the native renderer before a material-specific + /// alpha reference is written. The native Ngi32 initialiser stores one + /// in `D3DRS_ALPHAREF`; callers that do not have a material-specific value + /// should pass this constant explicitly. Zero remains a valid explicit + /// alpha reference. + pub const NATIVE_DEFAULT_ALPHA_TEST_REFERENCE: u8 = 1; + /// Returns the canonical key used for Vulkan pipeline selection. #[must_use] pub fn pipeline_key(self) -> PipelineKey { @@ -165,12 +445,75 @@ impl VulkanStaticDrawRange { } /// One diffuse material texture keyed by an original MSH batch selector. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct VulkanStaticMaterial { /// Positional material selector used by one or more source batches. pub material_index: u16, /// Decoded RGBA8 diffuse texture for this selector. pub texture: VulkanStaticTexture, + /// Phase parameters 4..6, normalized directional light RGB coefficient. + pub directional_rgb: [f32; 3], + /// Phase parameters 0..2, normalized additive RGB coefficient. + pub additive_rgb: [f32; 3], + /// Normalized atlas transform for base texture `[x, y, w, h]`. + pub uv_transform: [f32; 4], + /// Optional Land2 detail texture paired with the base texture. + pub detail_texture: Option, + /// Optional Land1 overlay texture selected by the face tag high byte. + pub overlay_texture: Option, + /// Optional Land2 detail texture paired with the overlay texture. + pub overlay_detail_texture: Option, + /// MAT0 phase diffuse alpha, normalized from the legacy 0..=255 byte. + pub diffuse_alpha: f32, + /// MAT0 phase alpha for the optional overlay material. + pub overlay_diffuse_alpha: Option, + /// Phase parameters 4..6 for the optional overlay material. + pub overlay_directional_rgb: Option<[f32; 3]>, + /// Phase parameters 0..2 for the optional overlay material. + pub overlay_additive_rgb: Option<[f32; 3]>, + /// Phase parameters 8..10, normalized base specular RGB coefficient. + /// + /// Specular data is kept in a storage buffer instead of push constants so + /// the vertex stage can evaluate the native Gouraud highlight while the + /// material's other dynamic values retain their existing ABI. + pub specular_rgb: [f32; 3], + /// Phase parameter 16 for the base material, carried as the native byte. + pub specular_power: u8, + /// Phase parameters 8..10 for the optional overlay material. + pub overlay_specular_rgb: Option<[f32; 3]>, + /// Phase parameter 16 for the optional overlay material. + pub overlay_specular_power: Option, + /// Normalized atlas transform for the optional detail texture. + pub detail_uv_transform: [f32; 4], + /// Normalized atlas transform for the optional overlay texture. + pub overlay_uv_transform: Option<[f32; 4]>, + /// Normalized atlas transform for the optional overlay detail texture. + pub overlay_detail_uv_transform: Option<[f32; 4]>, + /// Environment layers bypass terrain lighting and fog while retaining the + /// same descriptor/pipeline path. + pub unlit: bool, + /// Native sky mode 4: blend base and secondary textures by the secondary + /// texture alpha before multiplying by the vertex diffuse color. + pub sky_nebula_stars: bool, + /// Native terrain mode 3: combine base and secondary lightmap textures + /// without the regular terrain `2 * TEX0 * TEX1` combiner or NdotL. + pub lightmap_mode: bool, + /// Push material mode bit 4 and map this sky layer to the Vulkan far + /// plane without changing its world-space vertices or UVs. This is a + /// renderer projection choice; it is not a claim about the original D3D + /// depth-bit encoding. + pub sky_far_depth: bool, +} + +/// One authored RGBA8 mip level accepted by the Vulkan texture upload path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanStaticTextureMip { + /// Mip width in texels. + pub width: u32, + /// Mip height in texels. + pub height: u32, + /// Row-major RGBA8 pixels. + pub rgba8: Vec, } /// Decoded RGBA8 image accepted by the initial Vulkan texture upload path. @@ -182,6 +525,10 @@ pub struct VulkanStaticTexture { pub height: u32, /// Row-major RGBA8 pixels. pub rgba8: Vec, + /// Authored mip levels, including mip zero, when available. + /// + /// An empty vector requests deterministic CPU generation from `rgba8`. + pub mip_levels: Vec, } impl VulkanStaticTexture { @@ -200,7 +547,46 @@ impl VulkanStaticTexture { Some(expected) if expected != self.rgba8.len() => { Err("static texture rgba8 byte count does not match extent") } - Some(_) => Ok(()), + Some(_) => { + if self.mip_levels.is_empty() { + return Ok(()); + } + // TEXM files commonly stop at the authored 4x4 level. Vulkan + // permits a partial mip chain; only the supplied level + // extents and bytes need validation. + if self.mip_levels.len() + > usize::try_from(self.width.max(self.height)) + .unwrap_or(usize::MAX) + .ilog2() as usize + + 1 + { + return Err("static texture authored mip count exceeds extent"); + } + let mut width = self.width; + let mut height = self.height; + for (level, mip) in self.mip_levels.iter().enumerate() { + if mip.width != width || mip.height != height { + return Err("static texture authored mip extent does not match level"); + } + let expected = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)); + if expected != Some(mip.rgba8.len()) { + return Err("static texture authored mip byte count does not match extent"); + } + if level == 0 && mip.rgba8 != self.rgba8 { + return Err("static texture authored mip zero does not match rgba8"); + } + width = (width / 2).max(1); + height = (height / 2).max(1); + } + Ok(()) + } } } } @@ -216,6 +602,60 @@ pub(super) fn resolve_draw_texture_indices( ) -> Result, &'static str> { for (index, material) in materials.iter().enumerate() { material.texture.validate()?; + if let Some(texture) = &material.detail_texture { + texture.validate()?; + } + if let Some(texture) = &material.overlay_texture { + texture.validate()?; + } + if let Some(texture) = &material.overlay_detail_texture { + texture.validate()?; + } + if !material.diffuse_alpha.is_finite() || !(0.0..=1.0).contains(&material.diffuse_alpha) { + return Err("static material diffuse alpha must be finite and 0..1"); + } + if material + .directional_rgb + .into_iter() + .chain(material.additive_rgb) + .chain(material.specular_rgb) + .chain(material.overlay_specular_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err("static material lighting and specular RGB parameters must be finite and non-negative"); + } + if material + .uv_transform + .into_iter() + .chain(material.detail_uv_transform) + .any(|value| !value.is_finite()) + { + return Err("static material UV transforms must be finite"); + } + if material + .overlay_diffuse_alpha + .is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha)) + { + return Err("static material overlay diffuse alpha must be finite and 0..1"); + } + if material + .overlay_directional_rgb + .into_iter() + .flatten() + .chain(material.overlay_additive_rgb.into_iter().flatten()) + .any(|value| !value.is_finite() || value < 0.0) + { + return Err("static material overlay RGB parameters must be finite and non-negative"); + } + if material + .overlay_uv_transform + .into_iter() + .flatten() + .chain(material.overlay_detail_uv_transform.into_iter().flatten()) + .any(|value| !value.is_finite()) + { + return Err("static material overlay UV transforms must be finite"); + } if materials[..index] .iter() .any(|previous| previous.material_index == material.material_index) @@ -247,17 +687,26 @@ impl VulkanStaticMesh { VulkanStaticVertex { position: [0.0, -0.55, 0.0], color: [1.0, 0.2, 0.2], + normal: [0.0, 0.0, 1.0], uv: [0.5, 0.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }, VulkanStaticVertex { position: [0.55, 0.55, 0.0], color: [0.2, 1.0, 0.2], + normal: [0.0, 0.0, 1.0], uv: [1.0, 1.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }, VulkanStaticVertex { position: [-0.55, 0.55, 0.0], color: [0.2, 0.4, 1.0], + normal: [0.0, 0.0, 1.0], uv: [0.0, 1.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }, ], indices: vec![0, 1, 2], @@ -265,6 +714,8 @@ impl VulkanStaticMesh { first_index: 0, index_count: 3, material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }], @@ -275,6 +726,22 @@ impl VulkanStaticMesh { if self.vertices.is_empty() { return Err("static mesh has no vertices"); } + if self.vertices.iter().any(|vertex| { + !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()) + || !(0.0..=1.0).contains(&vertex.overlay_alpha) + }) { + return Err( + "static mesh vertex attributes must be finite and overlay alpha must be 0..1", + ); + } if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) { return Err("static mesh indices must contain complete triangles"); } @@ -311,6 +778,53 @@ impl VulkanStaticMesh { mod static_mesh_tests { use super::*; + #[test] + fn frame_uniforms_use_std140_sized_slots_and_preserve_dynamic_values() { + let frame = VulkanFrameUniforms { + clip_from_world: std::array::from_fn(|index| index as f32), + directional_lights: [ + VulkanDirectionalLight { + direction: [1.0, 2.0, 3.0], + rgb: [4.0, 5.0, 6.0], + coefficients: [7.0, 8.0, 9.0], + active: true, + }, + VulkanDirectionalLight::default(), + VulkanDirectionalLight::default(), + VulkanDirectionalLight::default(), + ], + point_light: VulkanPointLight { + position: [28.0, 29.0, 30.0], + rgb: [31.0, 32.0, 33.0], + range: 34.0, + coefficients: [35.0, 36.0, 37.0], + active: true, + }, + lighting_floor: [7.0, 8.0, 9.0], + fog_color: [10.0, 11.0, 12.0], + fog_start: 13.0, + fog_end: 14.0, + camera_position: [15.0, 16.0, 17.0], + }; + let bytes = frame.to_ne_bytes(); + assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES); + let read = + |offset: usize| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().expect("f32")); + assert_eq!(read(0), 0.0); + assert_eq!(read(60), 15.0); + assert_eq!(read(64), 1.0); + assert_eq!(read(76), 1.0); + assert_eq!(read(128), 4.0); + assert_eq!(read(192), 7.0); + assert_eq!(read(256), 28.0); + assert_eq!(read(272), 31.0); + assert_eq!(read(288), 35.0); + assert_eq!(read(304), 7.0); + assert_eq!(read(320), 10.0); + assert_eq!(read(336), 13.0); + assert_eq!(read(352), 15.0); + } + #[test] fn smoke_triangle_is_valid_complete_geometry() { let mesh = VulkanStaticMesh::smoke_triangle(); @@ -357,6 +871,54 @@ mod static_mesh_tests { assert_eq!(camera.clip_from_world[7], 0.65_f32.sin()); } + #[test] + fn legacy_camera_maps_world_up_to_top_of_positive_height_vulkan_viewport() { + let transform = 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, 1024, 768], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + }; + + let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection) + .expect("recovered camera inputs are valid"); + // The raw identity camera's recovered view maps world +X to depth + // and world +Z to camera up. + let world_up_at_positive_depth = [10.0, 0.0, 1.0, 1.0]; + let clip_y = (0..4) + .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 1]) + .sum::(); + let clip_w = (0..4) + .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 3]) + .sum::(); + + assert!(clip_w > 0.0); + assert!( + clip_y / clip_w < 0.0, + "world up must map to the viewport top" + ); + } + #[test] fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() { let identity = VulkanStaticCamera::default().clip_from_world; @@ -387,6 +949,8 @@ mod static_mesh_tests { first_index: 0, index_count: 2, material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }], @@ -415,6 +979,8 @@ mod static_mesh_tests { first_index: 0, index_count: 3, material_index: 7, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }, @@ -422,6 +988,8 @@ mod static_mesh_tests { first_index: 3, index_count: 3, material_index: 2, + lightmap_index: u8::MAX, + batch_flags: 0, pipeline_state: LegacyPipelineState::default(), alpha_test_reference: 0, }, @@ -430,15 +998,58 @@ mod static_mesh_tests { width: 1, height: 1, rgba8: vec![255; 4], + mip_levels: Vec::new(), }; let materials = [ VulkanStaticMaterial { material_index: 2, texture: texture(), + detail_texture: None, + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + specular_rgb: [0.0; 3], + specular_power: 0, + overlay_specular_rgb: None, + overlay_specular_power: None, + uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_directional_rgb: None, + overlay_additive_rgb: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, }, VulkanStaticMaterial { material_index: 7, texture: texture(), + detail_texture: None, + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + specular_rgb: [0.0; 3], + specular_power: 0, + overlay_specular_rgb: None, + overlay_specular_power: None, + uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_directional_rgb: None, + overlay_additive_rgb: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, }, ]; @@ -480,6 +1091,24 @@ mod static_mesh_tests { }; assert_eq!(disabled.alpha_test_cutoff(), 0.0); assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0); + let explicit_zero = VulkanStaticDrawRange { + pipeline_state: LegacyPipelineState { + alpha_test: true, + ..LegacyPipelineState::default() + }, + alpha_test_reference: 0, + ..base + }; + assert_eq!(explicit_zero.alpha_test_cutoff(), 0.0); + let native_default = VulkanStaticDrawRange { + pipeline_state: LegacyPipelineState { + alpha_test: true, + ..LegacyPipelineState::default() + }, + alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + ..base + }; + assert_eq!(native_default.alpha_test_cutoff(), 1.0 / 255.0); } } @@ -727,10 +1356,46 @@ pub struct VulkanSmokeRenderer { pub(super) swapchain_resources: Option, pub(super) vertex_buffer: Option, pub(super) index_buffer: Option, + /// Positional material slots mapped to the owned image list. Fallback + /// stages may share one image without coupling independently animated + /// material textures. + pub(super) texture_slot_indices: Vec, pub(super) textures: Vec, + pub(super) vertex_count: usize, + /// Retained index allocation length. The upload path grows this only when + /// a frame actually needs more shared indices and never shrinks it. + pub(super) index_capacity: usize, + pub(super) draw_range_capacities: Vec, pub(super) draw_ranges: Vec, pub(super) draw_texture_indices: Vec, + /// Stable range handles used when recording the native pass order. + pub(super) draw_order: Vec, + /// Native camera-sort values returned by the draw-item camera callback. + pub(super) draw_range_sort_keys: Vec>, + /// Whether the stable order must be rebuilt before the next command + /// buffer. Range setters are called in batches by the game loop. + pub(super) draw_order_dirty: bool, + /// World queue classification for each stable range handle. `None` keeps + /// a fixed screen/sky/weather pass; `Some(false)` is world opaque and + /// `Some(true)` is world transparent. + pub(super) draw_range_world_queue: Vec>, + /// Pipeline state before transparent queue depth policy is applied. + pub(super) draw_range_base_pipeline_states: Vec, + pub(super) material_alphas: Vec<[f32; 2]>, + pub(super) material_lighting: Vec<[[f32; 3]; 4]>, + pub(super) material_uv_transforms: Vec<[[f32; 4]; 4]>, + /// Base and overlay specular records uploaded to the dynamic SSBO. + pub(super) material_specular: Vec<[[f32; 4]; 2]>, + /// Original selector for each positional material record. + pub(super) material_selectors: Vec, + /// 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. + pub(super) material_unlit: Vec, + pub(super) readback_enabled: bool, pub(super) camera: VulkanStaticCamera, + pub(super) frame_uniforms: VulkanFrameUniforms, pub(super) frame_sync: Vec, pub(super) images_in_flight: Vec, pub(super) current_frame: usize, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index c175f0d..b8049d3 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -8,38 +8,85 @@ use fparkan_render::{ use std::collections::BTreeMap; use super::{ - color_subresource_range, create_depth_attachment, destroy_depth_attachment, - VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe, - VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe, - TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, + color_subresource_range, create_depth_attachment, create_material_specular_buffer, + destroy_depth_attachment, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, + VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe, + TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES, + VULKAN_MATERIAL_SPECULAR_RECORD_BYTES, }; pub(super) struct VulkanSwapchainResources { pub(super) image_views: Vec, pub(super) images: Vec, pub(super) readback_buffers: Vec, - pub(super) depth_attachment: VulkanDepthAttachment, + /// One depth image per swapchain image so frames can overlap safely. + pub(super) depth_attachments: Vec, + /// A render-finished semaphore dedicated to each swapchain image. + pub(super) render_finished: Vec, pub(super) render_pass: vk::RenderPass, pub(super) pipeline_layout: vk::PipelineLayout, pub(super) descriptor_set_layout: vk::DescriptorSetLayout, pub(super) descriptor_pool: vk::DescriptorPool, pub(super) descriptor_sets: Vec, pub(super) sampler: vk::Sampler, + pub(super) frame_uniform_buffer: VulkanAllocatedBuffer, + pub(super) frame_uniform_stride: u64, + pub(super) material_specular_buffer: VulkanAllocatedBuffer, + pub(super) material_specular_stride: u64, /// Live graphics pipelines indexed by canonical backend-neutral state. pub(super) pipelines: BTreeMap, pub(super) framebuffers: Vec, pub(super) command_buffers: Vec, } +pub(super) fn update_material_texture_descriptor( + device: &VulkanLogicalDeviceProbe, + resources: &VulkanSwapchainResources, + material_index: usize, + texture_slot: usize, + texture: &VulkanAllocatedImage, +) -> Result<(), VulkanSmokeRendererError> { + if texture_slot >= 4 { + return Err(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material texture slot is out of bounds", + }); + } + let descriptor_set = resources + .descriptor_sets + .get(material_index) + .copied() + .ok_or(VulkanSmokeRendererError::InvalidStaticTexture { + context: "dynamic material descriptor set is out of bounds", + })?; + let image_info = vk::DescriptorImageInfo::default() + .sampler(resources.sampler) + .image_view(texture.view) + .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL); + let write = vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(u32::try_from(texture_slot).unwrap_or(u32::MAX)) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(std::slice::from_ref(&image_info)); + // SAFETY: the renderer idles the device before changing a descriptor that + // may be referenced by an in-flight command buffer. + unsafe { device.device().update_descriptor_sets(&[write], &[]) }; + Ok(()) +} + struct PartialSwapchainResources { image_views: Vec, readback_buffers: Vec, - depth_attachment: Option, + depth_attachments: Vec, + render_finished: Vec, render_pass: Option, pipeline_layout: Option, descriptor_set_layout: Option, descriptor_pool: Option, sampler: Option, + frame_uniform_buffer: Option, + frame_uniform_stride: u64, + material_specular_buffer: Option, + material_specular_stride: u64, pipelines: BTreeMap, framebuffers: Vec, command_buffers: Vec, @@ -53,6 +100,7 @@ pub(super) fn create_swapchain_resources( vertex_buffer: &VulkanAllocatedBuffer, index_buffer: &VulkanAllocatedBuffer, textures: &[VulkanAllocatedImage], + texture_slot_indices: &[usize], draw_ranges: &[super::VulkanStaticDrawRange], depth_request: DepthStencilSupport, reuse_command_pool: bool, @@ -74,16 +122,57 @@ pub(super) fn create_swapchain_resources( swapchain.report.plan.format.format, )?, readback_buffers: Vec::new(), - depth_attachment: None, + depth_attachments: Vec::new(), + render_finished: Vec::new(), render_pass: None, pipeline_layout: None, descriptor_set_layout: None, descriptor_pool: None, sampler: None, + frame_uniform_buffer: None, + frame_uniform_stride: 0, + material_specular_buffer: None, + material_specular_stride: 0, pipelines: BTreeMap::new(), framebuffers: Vec::new(), command_buffers: Vec::new(), }; + let semaphore_info = vk::SemaphoreCreateInfo::default(); + for _ in &images { + // SAFETY: The semaphore belongs to this live logical device and is + // destroyed with the swapchain resources. + match unsafe { device.device().create_semaphore(&semaphore_info, None) } { + Ok(semaphore) => partial.render_finished.push(semaphore), + Err(result) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateSemaphore(render_finished)", + result, + }); + } + } + } + let (frame_uniform_buffer, frame_uniform_stride) = + match super::create_frame_uniform_buffer(instance, device) { + Ok(buffer) => buffer, + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + }; + partial.frame_uniform_stride = frame_uniform_stride; + partial.frame_uniform_buffer = Some(frame_uniform_buffer); + let material_count = texture_slot_indices.len() / 4; + let (material_specular_buffer, material_specular_stride) = + match create_material_specular_buffer(instance, device, material_count) { + Ok(buffer) => buffer, + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + }; + partial.material_specular_stride = material_specular_stride; + partial.material_specular_buffer = Some(material_specular_buffer); let ( depth_attachment, render_pass, @@ -99,8 +188,17 @@ pub(super) fn create_swapchain_resources( swapchain.report.plan.format.format, swapchain.report.plan.extent, textures, + texture_slot_indices, draw_ranges, depth_request, + partial + .frame_uniform_buffer + .as_ref() + .expect("frame uniform buffer is present before descriptor creation"), + partial + .material_specular_buffer + .as_ref() + .expect("material specular buffer is present before descriptor creation"), ) { Ok(bundle) => bundle, Err(error) => { @@ -108,19 +206,35 @@ pub(super) fn create_swapchain_resources( return Err(error); } }; - let depth_view = depth_attachment.image.view; - partial.depth_attachment = Some(depth_attachment); + // Keep every handle from the bundle in the partial owner before creating + // the remaining per-image depth attachments. That loop can fail after the + // first attachment, and its error path must destroy the whole bundle. + partial.depth_attachments.push(depth_attachment); partial.render_pass = Some(render_pass); partial.pipeline_layout = Some(pipeline_layout); partial.descriptor_set_layout = Some(descriptor_set_layout); partial.descriptor_pool = Some(descriptor_pool); partial.sampler = Some(sampler); partial.pipelines = pipelines; + for _ in 1..images.len() { + match create_depth_attachment( + instance, + device, + swapchain.report.plan.extent, + depth_request, + ) { + Ok(attachment) => partial.depth_attachments.push(attachment), + Err(error) => { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(error); + } + } + } let framebuffers = match create_swapchain_framebuffers( device, render_pass, &partial.image_views, - depth_view, + &partial.depth_attachments, swapchain.report.plan.extent, ) { Ok(framebuffers) => framebuffers, @@ -168,24 +282,44 @@ pub(super) fn create_swapchain_resources( }; partial.command_buffers = command_buffers; let _ = (vertex_buffer, index_buffer); - let depth_attachment = - partial - .depth_attachment - .take() - .ok_or(VulkanSmokeRendererError::InvariantViolation { - context: "depth attachment ownership after swapchain setup", - })?; + if partial.depth_attachments.len() != images.len() + || partial.render_finished.len() != images.len() + { + destroy_partial_swapchain_resources(device, command_pool, partial); + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "swapchain per-image resource count after setup", + }); + } Ok(VulkanSwapchainResources { image_views: partial.image_views, images, readback_buffers: partial.readback_buffers, - depth_attachment, - render_pass, - pipeline_layout, - descriptor_set_layout, - descriptor_pool, + depth_attachments: partial.depth_attachments, + render_finished: partial.render_finished, + render_pass: partial + .render_pass + .expect("render pass is present after successful swapchain setup"), + pipeline_layout: partial + .pipeline_layout + .expect("pipeline layout is present after successful swapchain setup"), + descriptor_set_layout: partial + .descriptor_set_layout + .expect("descriptor set layout is present after successful swapchain setup"), + descriptor_pool: partial + .descriptor_pool + .expect("descriptor pool is present after successful swapchain setup"), descriptor_sets, - sampler, + sampler: partial + .sampler + .expect("sampler is present after successful swapchain setup"), + frame_uniform_buffer: partial + .frame_uniform_buffer + .expect("frame uniform buffer is present after successful swapchain setup"), + frame_uniform_stride: partial.frame_uniform_stride, + material_specular_buffer: partial + .material_specular_buffer + .expect("material specular buffer is present after successful swapchain setup"), + material_specular_stride: partial.material_specular_stride, pipelines: partial.pipelines, framebuffers: partial.framebuffers, command_buffers: partial.command_buffers, @@ -210,8 +344,11 @@ fn create_swapchain_pipeline_bundle( format: i32, extent: (u32, u32), textures: &[VulkanAllocatedImage], + texture_slot_indices: &[usize], draw_ranges: &[super::VulkanStaticDrawRange], depth_request: DepthStencilSupport, + frame_uniform_buffer: &VulkanAllocatedBuffer, + material_specular_buffer: &VulkanAllocatedBuffer, ) -> Result< ( VulkanDepthAttachment, @@ -234,7 +371,14 @@ fn create_swapchain_pipeline_bundle( } }; let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) = - create_texture_descriptor_bundle(device, textures).inspect_err(|_| { + create_texture_descriptor_bundle( + device, + textures, + texture_slot_indices, + frame_uniform_buffer, + material_specular_buffer, + ) + .inspect_err(|_| { // SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure. unsafe { device.device().destroy_render_pass(render_pass, None) }; destroy_depth_attachment(device, &depth_attachment); @@ -289,12 +433,23 @@ fn create_swapchain_framebuffers( device: &VulkanLogicalDeviceProbe, render_pass: vk::RenderPass, image_views: &[vk::ImageView], - depth_view: vk::ImageView, + depth_attachments: &[VulkanDepthAttachment], extent: (u32, u32), ) -> Result, VulkanSmokeRendererError> { + if image_views.len() != depth_attachments.len() { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "swapchain color/depth attachment count", + }); + } let mut framebuffers = Vec::with_capacity(image_views.len()); - for image_view in image_views.iter().copied() { - match create_framebuffer(device, render_pass, image_view, depth_view, extent) { + for (image_view, depth_attachment) in image_views.iter().copied().zip(depth_attachments) { + match create_framebuffer( + device, + render_pass, + image_view, + depth_attachment.image.view, + extent, + ) { Ok(framebuffer) => framebuffers.push(framebuffer), Err(error) => { // SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure. @@ -339,7 +494,7 @@ fn create_image_view( .image(image) .view_type(vk::ImageViewType::TYPE_2D) .format(vk::Format::from_raw(format)) - .subresource_range(color_subresource_range()); + .subresource_range(color_subresource_range(1)); // SAFETY: The image comes from the live swapchain and the subresource range covers its color aspect. unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| { VulkanSmokeRendererError::VulkanOperation { @@ -384,10 +539,21 @@ fn create_render_pass( let dependency = vk::SubpassDependency::default() .src_subpass(vk::SUBPASS_EXTERNAL) .dst_subpass(0) - .src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) - .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) + .src_stage_mask( + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT + | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS + | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS, + ) + .dst_stage_mask( + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT + | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS + | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS, + ) .src_access_mask(vk::AccessFlags::empty()) - .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE); + .dst_access_mask( + vk::AccessFlags::COLOR_ATTACHMENT_WRITE + | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE, + ); let attachments = [color_attachment, depth_attachment]; let subpasses = [subpass]; let dependencies = [dependency]; @@ -409,16 +575,16 @@ fn create_pipeline_layout( descriptor_set_layout: vk::DescriptorSetLayout, ) -> Result { let set_layouts = [descriptor_set_layout]; - let push_constant_ranges = [ - vk::PushConstantRange::default() - .stage_flags(vk::ShaderStageFlags::VERTEX) - .offset(0) - .size(64), - vk::PushConstantRange::default() - .stage_flags(vk::ShaderStageFlags::FRAGMENT) - .offset(64) - .size(u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX)), - ]; + // Material data occupies one 128-byte push-constant allocation shared by + // both stages; the vertex stage also reads its specular pair from the + // dynamic storage buffer at descriptor binding 5. Camera data is supplied + // by the dynamic frame UBO at descriptor binding 4. + // Expose the complete block from offset zero so drivers validate the + // fragment member's declared offset against the pipeline layout. + let push_constant_ranges = [vk::PushConstantRange::default() + .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT) + .offset(0) + .size(32 * u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX))]; let create_info = vk::PipelineLayoutCreateInfo::default() .set_layouts(&set_layouts) .push_constant_ranges(&push_constant_ranges); @@ -434,6 +600,9 @@ fn create_pipeline_layout( fn create_texture_descriptor_bundle( device: &VulkanLogicalDeviceProbe, textures: &[VulkanAllocatedImage], + texture_slot_indices: &[usize], + frame_uniform_buffer: &VulkanAllocatedBuffer, + material_specular_buffer: &VulkanAllocatedBuffer, ) -> Result< ( vk::DescriptorSetLayout, @@ -443,12 +612,32 @@ fn create_texture_descriptor_bundle( ), VulkanSmokeRendererError, > { - let binding = vk::DescriptorSetLayoutBinding::default() - .binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(1) - .stage_flags(vk::ShaderStageFlags::FRAGMENT); - let bindings = [binding]; + const TEXTURES_PER_MATERIAL: usize = 4; + const FRAME_BINDING: u32 = 4; + const MATERIAL_SPECULAR_BINDING: u32 = 5; + let mut bindings = (0..TEXTURES_PER_MATERIAL) + .map(|binding| { + vk::DescriptorSetLayoutBinding::default() + .binding(u32::try_from(binding).unwrap_or(u32::MAX)) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::FRAGMENT) + }) + .collect::>(); + bindings.push( + vk::DescriptorSetLayoutBinding::default() + .binding(FRAME_BINDING) + .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT), + ); + bindings.push( + vk::DescriptorSetLayoutBinding::default() + .binding(MATERIAL_SPECULAR_BINDING) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX), + ); let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings); // SAFETY: The layout description is stack-owned and references no external memory. let layout = unsafe { @@ -460,23 +649,40 @@ fn create_texture_descriptor_bundle( context: "vkCreateDescriptorSetLayout", result, })?; - let texture_count = u32::try_from(textures.len()).map_err(|_| { + let texture_count = u32::try_from(texture_slot_indices.len()).map_err(|_| { VulkanSmokeRendererError::InvariantViolation { context: "static material texture count exceeds Vulkan descriptor limit", } })?; - if texture_count == 0 { + if texture_count == 0 + || !texture_slot_indices + .len() + .is_multiple_of(TEXTURES_PER_MATERIAL) + || texture_slot_indices + .iter() + .any(|texture_index| *texture_index >= textures.len()) + { + // SAFETY: The layout was created above on this device and is rolled + // back before reporting malformed material-slot input. + unsafe { device.device().destroy_descriptor_set_layout(layout, None) }; return Err(VulkanSmokeRendererError::InvariantViolation { - context: "static material texture list is empty", + context: "static material texture slots are invalid", }); } + let material_count = texture_count / u32::try_from(TEXTURES_PER_MATERIAL).unwrap_or(1); let pool_size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .descriptor_count(texture_count); - let pool_sizes = [pool_size]; + let frame_pool_size = vk::DescriptorPoolSize::default() + .ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .descriptor_count(material_count); + let material_specular_pool_size = vk::DescriptorPoolSize::default() + .ty(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .descriptor_count(material_count); + let pool_sizes = [pool_size, frame_pool_size, material_specular_pool_size]; let pool_info = vk::DescriptorPoolCreateInfo::default() .pool_sizes(&pool_sizes) - .max_sets(texture_count); + .max_sets(material_count); let pool = // SAFETY: The pool description is stack-owned and reserves exactly one descriptor. unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| { @@ -487,7 +693,7 @@ fn create_texture_descriptor_bundle( result, } })?; - let layouts = vec![layout; textures.len()]; + let layouts = vec![layout; usize::try_from(material_count).unwrap_or(0)]; let allocate_info = vk::DescriptorSetAllocateInfo::default() .descriptor_pool(pool) .set_layouts(&layouts); @@ -511,7 +717,9 @@ fn create_texture_descriptor_bundle( .address_mode_u(vk::SamplerAddressMode::REPEAT) .address_mode_v(vk::SamplerAddressMode::REPEAT) .address_mode_w(vk::SamplerAddressMode::REPEAT) - .max_lod(0.0); + // The uploaded images carry a complete CPU-generated mip chain; the + // sampler clamps to the largest legal level for each image. + .max_lod(16.0); let sampler = // SAFETY: The sampler create info is stack-owned and has no unsupported optional features. unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| { @@ -525,27 +733,51 @@ fn create_texture_descriptor_bundle( result, } })?; - let image_infos = textures + let image_infos = texture_slot_indices .iter() - .map(|texture| { + .map(|texture_index| { + let texture = &textures[*texture_index]; vk::DescriptorImageInfo::default() .sampler(sampler) .image_view(texture.view) .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) }) .collect::>(); - let writes = descriptor_sets - .iter() - .copied() - .zip(image_infos.iter()) - .map(|(descriptor_set, image_info)| { + let frame_info = vk::DescriptorBufferInfo::default() + .buffer(frame_uniform_buffer.buffer) + .offset(0) + .range(VULKAN_FRAME_UNIFORM_BYTES as u64); + let material_specular_info = vk::DescriptorBufferInfo::default() + .buffer(material_specular_buffer.buffer) + .offset(0) + .range(VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64); + let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 2)); + for (set_index, descriptor_set) in descriptor_sets.iter().copied().enumerate() { + for binding in 0..TEXTURES_PER_MATERIAL { + let image_info = &image_infos[set_index * TEXTURES_PER_MATERIAL + binding]; + writes.push( + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(u32::try_from(binding).unwrap_or(u32::MAX)) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(std::slice::from_ref(image_info)), + ); + } + writes.push( vk::WriteDescriptorSet::default() .dst_set(descriptor_set) - .dst_binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .image_info(std::slice::from_ref(image_info)) - }) - .collect::>(); + .dst_binding(FRAME_BINDING) + .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&frame_info)), + ); + writes.push( + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(MATERIAL_SPECULAR_BINDING) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC) + .buffer_info(std::slice::from_ref(&material_specular_info)), + ); + } // SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition. unsafe { device.device().update_descriptor_sets(&writes, &[]) }; Ok((layout, pool, descriptor_sets, sampler)) @@ -598,15 +830,16 @@ fn create_graphics_pipeline( extent: (u32, u32), state: LegacyPipelineState, ) -> Result { - let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?; - let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) { - Ok(module) => module, - Err(error) => { - // SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure. - unsafe { device.device().destroy_shader_module(vertex_shader, None) }; - return Err(error); - } - }; + let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS, "vertex")?; + let fragment_shader = + match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS, "fragment") { + Ok(module) => module, + Err(error) => { + // SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure. + unsafe { device.device().destroy_shader_module(vertex_shader, None) }; + return Err(error); + } + }; let entry_point = c"main"; let shader_stages = [ vk::PipelineShaderStageCreateInfo::default() @@ -620,7 +853,7 @@ fn create_graphics_pipeline( ]; let vertex_binding = vk::VertexInputBindingDescription::default() .binding(0) - .stride(u32::try_from(8 * std::mem::size_of::()).unwrap_or(u32::MAX)) + .stride(u32::try_from(14 * std::mem::size_of::()).unwrap_or(u32::MAX)) .input_rate(vk::VertexInputRate::VERTEX); let vertex_attributes = [ vk::VertexInputAttributeDescription::default() @@ -636,8 +869,23 @@ fn create_graphics_pipeline( vk::VertexInputAttributeDescription::default() .binding(0) .location(2) - .format(vk::Format::R32G32_SFLOAT) + .format(vk::Format::R32G32B32_SFLOAT) .offset(u32::try_from(6 * std::mem::size_of::()).unwrap_or(u32::MAX)), + vk::VertexInputAttributeDescription::default() + .binding(0) + .location(3) + .format(vk::Format::R32G32_SFLOAT) + .offset(u32::try_from(9 * std::mem::size_of::()).unwrap_or(u32::MAX)), + vk::VertexInputAttributeDescription::default() + .binding(0) + .location(4) + .format(vk::Format::R32G32_SFLOAT) + .offset(u32::try_from(11 * std::mem::size_of::()).unwrap_or(u32::MAX)), + vk::VertexInputAttributeDescription::default() + .binding(0) + .location(5) + .format(vk::Format::R32_SFLOAT) + .offset(u32::try_from(13 * std::mem::size_of::()).unwrap_or(u32::MAX)), ]; let vertex_bindings = [vertex_binding]; let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default() @@ -685,6 +933,21 @@ fn create_graphics_pipeline( .depth_compare_op(vk::CompareOp::LESS_OR_EQUAL) .depth_bounds_test_enable(false) .stencil_test_enable(false); + let (blend_enable, src_color_blend_factor, dst_color_blend_factor) = match state.blend { + LegacyBlendMode::Opaque => (false, vk::BlendFactor::ONE, vk::BlendFactor::ZERO), + LegacyBlendMode::SourceAlpha => ( + true, + vk::BlendFactor::SRC_ALPHA, + vk::BlendFactor::ONE_MINUS_SRC_ALPHA, + ), + LegacyBlendMode::Additive => (true, vk::BlendFactor::SRC_ALPHA, vk::BlendFactor::ONE), + LegacyBlendMode::ZeroSourceColor => { + (true, vk::BlendFactor::ZERO, vk::BlendFactor::SRC_COLOR) + } + LegacyBlendMode::DestColorSourceColor => { + (true, vk::BlendFactor::DST_COLOR, vk::BlendFactor::SRC_COLOR) + } + }; let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default() .color_write_mask( vk::ColorComponentFlags::R @@ -692,12 +955,12 @@ fn create_graphics_pipeline( | vk::ColorComponentFlags::B | vk::ColorComponentFlags::A, ) - .blend_enable(state.blend == LegacyBlendMode::SourceAlpha) - .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA) - .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .blend_enable(blend_enable) + .src_color_blend_factor(src_color_blend_factor) + .dst_color_blend_factor(dst_color_blend_factor) .color_blend_op(vk::BlendOp::ADD) - .src_alpha_blend_factor(vk::BlendFactor::ONE) - .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .src_alpha_blend_factor(src_color_blend_factor) + .dst_alpha_blend_factor(dst_color_blend_factor) .alpha_blend_op(vk::BlendOp::ADD); let color_blend_attachments = [color_blend_attachment]; let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default() @@ -738,12 +1001,17 @@ fn create_graphics_pipeline( fn create_shader_module( device: &VulkanLogicalDeviceProbe, words: &[u32], + stage: &'static str, ) -> Result { let create_info = vk::ShaderModuleCreateInfo::default().code(words); // SAFETY: The SPIR-V slice points to static checked-in words and lives for the duration of the call. unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| { VulkanSmokeRendererError::VulkanOperation { - context: "vkCreateShaderModule", + context: if stage == "vertex" { + "vkCreateShaderModule(vertex)" + } else { + "vkCreateShaderModule(fragment)" + }, result: error, } }) @@ -821,7 +1089,9 @@ pub(super) fn destroy_swapchain_resources( device .device() .destroy_render_pass(resources.render_pass, None); - destroy_depth_attachment(device, &resources.depth_attachment); + for depth_attachment in resources.depth_attachments { + destroy_depth_attachment(device, &depth_attachment); + } for image_view in resources.image_views { device.device().destroy_image_view(image_view, None); } @@ -829,6 +1099,21 @@ pub(super) fn destroy_swapchain_resources( device.device().destroy_buffer(buffer.buffer, None); device.device().free_memory(buffer.memory, None); } + for semaphore in resources.render_finished { + device.device().destroy_semaphore(semaphore, None); + } + device + .device() + .destroy_buffer(resources.frame_uniform_buffer.buffer, None); + device + .device() + .free_memory(resources.frame_uniform_buffer.memory, None); + device + .device() + .destroy_buffer(resources.material_specular_buffer.buffer, None); + device + .device() + .free_memory(resources.material_specular_buffer.memory, None); } } @@ -871,7 +1156,7 @@ fn destroy_partial_swapchain_resources( if let Some(render_pass) = partial.render_pass { device.device().destroy_render_pass(render_pass, None); } - if let Some(depth_attachment) = partial.depth_attachment { + for depth_attachment in partial.depth_attachments { destroy_depth_attachment(device, &depth_attachment); } for image_view in partial.image_views { @@ -881,5 +1166,16 @@ fn destroy_partial_swapchain_resources( device.device().destroy_buffer(buffer.buffer, None); device.device().free_memory(buffer.memory, None); } + for semaphore in partial.render_finished { + device.device().destroy_semaphore(semaphore, None); + } + if let Some(buffer) = partial.frame_uniform_buffer { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } + if let Some(buffer) = partial.material_specular_buffer { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } } } diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index 740c13d..b8a3d71 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -386,13 +386,13 @@ fn static_surface_extension_name_is_decoded() { } #[test] -fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() { +fn swapchain_plan_prefers_unorm_mailbox_and_clamps_extent() { let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan"); assert_eq!( plan.format, VulkanSurfaceFormat { - format: vk::Format::B8G8R8A8_SRGB.as_raw(), + format: vk::Format::R8G8B8A8_UNORM.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), } ); @@ -444,7 +444,7 @@ fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() { assert_eq!( plan.format, VulkanSurfaceFormat { - format: vk::Format::B8G8R8A8_SRGB.as_raw(), + format: vk::Format::R8G8B8A8_UNORM.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), } ); @@ -479,6 +479,66 @@ fn checked_in_shaders_have_valid_spirv_containers() { assert_eq!(validate_triangle_shaders(), Ok(())); } +#[test] +fn fragment_shader_dispatches_all_four_material_modes() { + let fragment = include_str!("../../shaders/triangle.frag"); + assert!(fragment.contains("int mode = int(round(material.material_mode));")); + assert!(fragment.contains("mode == 0")); + assert!(fragment.contains("mode == 1")); + assert!(fragment.contains("mode == 2")); + assert!(fragment.contains("mode == 3")); + assert!(!fragment.contains("material_mode > 1.5")); + assert!(!fragment.contains("material_mode > 2.5")); +} + +#[test] +fn native_specular_reference_uses_squared_power_and_total_overflow_curve() { + fn reference_power(cosine: f32, power: u8) -> f32 { + if power == 0 { + return 0.0; + } + let mut result = cosine; + for _ in 0..power.saturating_sub(1) { + result *= result; + } + result + } + fn reference_curve(value: f32) -> f32 { + if value <= 1.0 { + 0.8 * value + } else if value <= 3.0 { + 0.1 * value + 0.7 + } else { + 1.0 + } + } + + assert_eq!(reference_power(0.5, 0), 0.0); + assert!((reference_power(0.5, 1) - 0.5).abs() < 1e-6); + assert!((reference_power(0.5, 2) - 0.25).abs() < 1e-6); + assert!((reference_power(0.5, 3) - 0.0625).abs() < 1e-6); + let compressed_diffuse = 1.5; + let material_specular = 0.75; + assert!((reference_curve(material_specular + (compressed_diffuse - 1.0)) - 0.825).abs() < 1e-6); + + let vertex = include_str!("../../shaders/triangle.vert"); + assert!(vertex.contains("result *= result;")); + assert!( + vertex.contains("vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));") + ); + assert!(vertex.contains("if (power <= 0.0 || cosine <= 0.0)")); +} + +#[test] +fn shader_computes_radial_fog_per_vertex_and_interpolates_it() { + let vertex = include_str!("../../shaders/triangle.vert"); + let fragment = include_str!("../../shaders/triangle.frag"); + assert!(vertex.contains("out_fog_factor = native_fog_factor(in_position);")); + assert!(fragment.contains("layout(location = 4) in float in_fog_factor;")); + assert!(!fragment.contains("distance(in_world_position")); + assert!(!fragment.contains("float fog_factor()")); +} + #[test] fn shader_container_rejects_invalid_spirv() { assert_eq!( diff --git a/adapters/fparkan-render-vulkan/src/policy.rs b/adapters/fparkan-render-vulkan/src/policy.rs index ce7b59e..e1f01c8 100644 --- a/adapters/fparkan-render-vulkan/src/policy.rs +++ b/adapters/fparkan-render-vulkan/src/policy.rs @@ -458,9 +458,24 @@ fn select_surface_format( .iter() .copied() .find(|format| { - format.format == vk::Format::B8G8R8A8_SRGB.as_raw() + format.format == vk::Format::R8G8B8A8_UNORM.as_raw() && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() }) + .or_else(|| { + formats.iter().copied().find(|format| { + format.format == vk::Format::B8G8R8A8_UNORM.as_raw() + && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() + }) + }) + // Keep an sRGB attachment as the final compatibility fallback. The + // fragment shader emits legacy byte-equivalent linear values, so an + // UNORM attachment is preferred whenever the surface exposes one. + .or_else(|| { + formats.iter().copied().find(|format| { + format.format == vk::Format::B8G8R8A8_SRGB.as_raw() + && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() + }) + }) .or_else(|| formats.first().copied()) .ok_or(VulkanSwapchainError::MissingSurfaceFormat) } @@ -470,7 +485,7 @@ fn undefined_surface_format_override( ) -> Option { match formats { [format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat { - format: vk::Format::B8G8R8A8_SRGB.as_raw(), + format: vk::Format::R8G8B8A8_UNORM.as_raw(), color_space: format.color_space, }), _ => None, diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index 780a21c..69535f3 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -6,14 +6,21 @@ license.workspace = true repository.workspace = true [dependencies] +fparkan-animation = { path = "../../crates/fparkan-animation" } fparkan-assets = { path = "../../crates/fparkan-assets" } +fparkan-fx = { path = "../../crates/fparkan-fx" } fparkan-path = { path = "../../crates/fparkan-path" } +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-prototype = { path = "../../crates/fparkan-prototype" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-terrain = { path = "../../crates/fparkan-terrain" } +fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" } fparkan-vfs = { path = "../../crates/fparkan-vfs" } +rodio = { version = "0.22.2", default-features = false, features = ["playback", "vorbis", "wav", "symphonia-adpcm"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" winit = { version = "0.30", default-features = false, features = ["rwh_06"] } diff --git a/apps/fparkan-game/src/audio.rs b/apps/fparkan-game/src/audio.rs new file mode 100644 index 0000000..2de6464 --- /dev/null +++ b/apps/fparkan-game/src/audio.rs @@ -0,0 +1,1798 @@ +//! Audio playback for a running mission. +//! +//! The game keeps this module deliberately small. Mission ambience is read +//! from the names in mission.cfg, samples are resolved through the same VFS +//! and resource archive formats as the rest of the game, and rodio owns the +//! output thread. A sample is decoded once to validate it before it is +//! queued, then its bytes are retained so a new source can be created when a +//! loop or one-shot starts. + +use fparkan_fx::environment::{SoundAction, SoundEvent}; +use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy}; +use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; +use fparkan_vfs::Vfs; +use rodio::mixer::Mixer; +use rodio::source::{ChannelVolume, SeekError}; +use rodio::{ + ChannelCount, Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, SampleRate, Source, +}; +use std::collections::BTreeMap; +use std::io::Cursor; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SWAV_TYPE: u32 = u32::from_le_bytes(*b"SWAV"); +const DEFAULT_MUSIC_TRACK: u8 = 2; +const FIRST_MUSIC_TRACK: u8 = 2; +const LAST_MUSIC_TRACK: u8 = 10; +const EAR_HALF_WIDTH: f32 = 0.15; +const VALIDATION_SAMPLES: usize = 64; +const AMBIENT_NIGHT_LENGTH: f32 = 1.1; +const AMBIENT_MIN_DELAY_SECONDS: f32 = 10.0; +const AMBIENT_DELAY_VARIANTS: u32 = 10; + +/// Position and orientation of the listener in world coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Listener { + /// Listener position. + pub position: [f32; 3], + /// Direction in which the camera looks. + pub forward: [f32; 3], + /// Camera up direction. + pub up: [f32; 3], +} + +impl Default for Listener { + fn default() -> Self { + Self { + position: [0.0; 3], + forward: [0.0, 1.0, 0.0], + up: [0.0, 0.0, 1.0], + } + } +} + +/// Audio loading/playback failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AudioError { + /// Mission path or archive path is not a valid relative resource path. + InvalidPath { path: String, message: String }, + /// Mission configuration could not be read. + MissionConfigRead { path: String, message: String }, + /// Mission configuration does not contain the required audio object. + MissionConfig { path: String, message: String }, + /// A declared archive could not be opened. + Archive { path: String, message: String }, + /// A declared archive entry is absent. + MissingEntry { archive: String, name: String }, + /// A declared entry exists but contains no payload. + EmptyEntry { archive: String, name: String }, + /// A payload is not the expected SWAV entry type. + WrongEntryType { + archive: String, + name: String, + type_id: Option, + }, + /// The payload has a valid container but cannot be decoded by rodio. + Decode { resource: String, message: String }, + /// A decoded container produced no samples. + NoSamples { resource: String }, + /// A direct VFS file such as a music track is missing or unreadable. + FileRead { path: String, message: String }, + /// Track selection is outside the names shipped by the original game. + UnsupportedMusicTrack(u8), + /// The operating system has no usable output device. + DeviceUnavailable { message: String }, + /// A listener direction is non-finite or has zero length. + InvalidListener { field: &'static str }, + /// A listener or emitter position is non-finite. + InvalidPosition { field: &'static str }, +} + +impl std::fmt::Display for AudioError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidPath { path, message } => { + write!(f, "invalid audio path {path}: {message}") + } + Self::MissionConfigRead { path, message } => { + write!(f, "failed to read mission audio config {path}: {message}") + } + Self::MissionConfig { path, message } => { + write!(f, "invalid mission audio config {path}: {message}") + } + Self::Archive { path, message } => { + write!(f, "failed to open audio archive {path}: {message}") + } + Self::MissingEntry { archive, name } => { + write!( + f, + "declared audio resource {name} is missing from {archive}" + ) + } + Self::EmptyEntry { archive, name } => { + write!(f, "declared audio resource {name} in {archive} is empty") + } + Self::WrongEntryType { + archive, + name, + type_id, + } => write!( + f, + "audio resource {name} in {archive} has type {type_id:?}, expected SWAV" + ), + Self::Decode { resource, message } => { + write!(f, "failed to decode audio resource {resource}: {message}") + } + Self::NoSamples { resource } => { + write!(f, "audio resource {resource} contains no samples") + } + Self::FileRead { path, message } => { + write!(f, "failed to read audio file {path}: {message}") + } + Self::UnsupportedMusicTrack(track) => { + write!( + f, + "music track Track{track:02}.ogg is outside Track02..Track10" + ) + } + Self::DeviceUnavailable { message } => { + write!(f, "audio output device is unavailable: {message}") + } + Self::InvalidListener { field } => { + write!( + f, + "audio listener direction {field} must be finite and non-zero" + ) + } + Self::InvalidPosition { field } => { + write!(f, "audio {field} position must be finite") + } + } + } +} + +impl std::error::Error for AudioError {} + +/// Parsed names from the two mission audio objects. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MissionAudioConfig { + /// Archive named by ambient_music_loop. + pub library: String, + /// Archive named by ambient_music_variation, or the loop archive when it + /// is omitted. + pub variation_library: String, + /// Theme loop name from THEME. + pub theme: String, + /// Ambient default variation names in numeric key order. + pub default_variations: Vec, + /// Ambient day variation names in numeric key order. + pub day_variations: Vec, + /// Ambient night variation names in numeric key order. + pub night_variations: Vec, +} + +#[derive(Clone, Debug)] +struct SoundAsset { + archive: String, + name: String, + bytes: Arc<[u8]>, +} + +impl SoundAsset { + fn resource_name(&self) -> String { + format!("{}:{}", self.archive, self.name) + } +} + +struct AudioLibrary { + vfs: Arc, + repository: Arc, + theme: SoundAsset, + default_variations: Vec, + day_variations: Vec, + night_variations: Vec, + music: BTreeMap>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VariationPool { + Default, + Day, + Night, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct VariationSelection { + pool: VariationPool, + index: Option, +} + +/// The two 16-bit states used by the original ambient variation selector. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct VariationRng { + a: u16, + b: u16, +} + +impl VariationRng { + fn from_clock(clock: u32) -> Self { + Self { + a: clock as u16, + b: (clock >> 16) as u16, + } + } + + fn step(&mut self) { + self.a = self.a.wrapping_shl(1) ^ self.b; + self.b = self.b.wrapping_shr(1) ^ self.a; + } + + fn choose(&mut self, length: usize, last_index: &mut i32) -> Option { + if length == 0 { + *last_index = -1; + return None; + } + if length == 1 { + *last_index = 0; + return Some(0); + } + for _ in 0..=u16::MAX { + self.step(); + let candidate = usize::from(self.b) % length; + if candidate as i32 != *last_index { + *last_index = candidate as i32; + return Some(candidate); + } + } + // A zero state is not produced by the native clock seed, but keeping + // this fallback makes malformed or deterministic test seeds harmless. + let candidate = usize::from(self.b) % length; + *last_index = candidate as i32; + Some(candidate) + } +} + +/// CRT-style delay generator kept separate from the ambient pool selector. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CrtRand { + state: u32, +} + +impl CrtRand { + fn new(state: u32) -> Self { + Self { state } + } + + fn next_u15(&mut self) -> u32 { + self.state = self.state.wrapping_mul(214_013).wrapping_add(2_531_011); + (self.state >> 16) & 0x7fff + } + + fn next_delay_seconds(&mut self) -> f32 { + AMBIENT_MIN_DELAY_SECONDS + (self.next_u15() % AMBIENT_DELAY_VARIANTS) as f32 + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct AmbienceScheduler { + elapsed_seconds: f32, + next_delay_seconds: f32, + pool: VariationPool, + variation_rng: VariationRng, + delay_rng: CrtRand, + last_index: i32, +} + +impl AmbienceScheduler { + fn new(clock_seed: u32, delay_seed: u32) -> Self { + Self { + elapsed_seconds: 0.0, + next_delay_seconds: 0.0, + pool: VariationPool::Default, + variation_rng: VariationRng::from_clock(clock_seed), + delay_rng: CrtRand::new(delay_seed), + last_index: -1, + } + } + + fn update( + &mut self, + dt_seconds: f32, + primary_rgb: [f32; 3], + default_len: usize, + day_len: usize, + night_len: usize, + ) -> Option { + self.pool = selected_pool(primary_rgb, day_len, night_len); + self.elapsed_seconds += finite_non_negative(dt_seconds); + if self.elapsed_seconds <= self.next_delay_seconds { + return None; + } + self.elapsed_seconds = 0.0; + let length = match self.pool { + VariationPool::Default => default_len, + VariationPool::Day => day_len, + VariationPool::Night => night_len, + }; + let index = self.variation_rng.choose(length, &mut self.last_index); + self.next_delay_seconds = self.delay_rng.next_delay_seconds(); + Some(VariationSelection { + pool: self.pool, + index, + }) + } +} + +fn finite_non_negative(value: f32) -> f32 { + if value.is_finite() { + value.max(0.0) + } else { + 0.0 + } +} + +fn random_seed() -> u32 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + (duration.as_nanos() as u64 as u32) ^ duration.subsec_nanos().rotate_left(13) +} + +fn selected_pool(primary_rgb: [f32; 3], day_len: usize, night_len: usize) -> VariationPool { + if day_len == 0 && night_len == 0 { + return VariationPool::Default; + } + let length = primary_rgb + .iter() + .map(|value| value * value) + .sum::() + .sqrt(); + if length.is_finite() && length <= AMBIENT_NIGHT_LENGTH { + VariationPool::Night + } else { + VariationPool::Day + } +} + +struct SpatialOneShot { + player: NativeSpatialPlayer, + volume: f32, +} + +#[derive(Clone, Copy, Debug)] +struct NativeSpatialState { + emitter_position: [f32; 3], + left_ear: [f32; 3], + right_ear: [f32; 3], + volume: f32, + min_distance: f32, + max_distance: f32, +} + +/// A spatial player with the native DirectSound distance curve. +/// +/// `rodio::SpatialPlayer` applies an inverse-square curve unconditionally. +/// Native FX sounds instead use the DirectSound min/max distance curve, so +/// this small wrapper keeps rodio's mono and stereo panning behavior while +/// supplying the native per-channel gains itself. +struct NativeSpatialPlayer { + player: Player, + state: Arc>, +} + +impl NativeSpatialPlayer { + fn connect_new( + mixer: &Mixer, + emitter_position: [f32; 3], + left_ear: [f32; 3], + right_ear: [f32; 3], + ) -> Self { + Self { + player: Player::connect_new(mixer), + state: Arc::new(Mutex::new(NativeSpatialState { + emitter_position, + left_ear, + right_ear, + volume: 1.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + })), + } + } + + fn set_emitter_position(&self, position: [f32; 3]) { + self.state + .lock() + .expect("audio state poisoned") + .emitter_position = position; + } + + fn set_left_ear_position(&self, position: [f32; 3]) { + self.state.lock().expect("audio state poisoned").left_ear = position; + } + + fn set_right_ear_position(&self, position: [f32; 3]) { + self.state.lock().expect("audio state poisoned").right_ear = position; + } + + fn set_sound_parameters(&self, min_distance: f32, max_distance: f32) { + let mut state = self.state.lock().expect("audio state poisoned"); + state.min_distance = min_distance; + state.max_distance = max_distance; + } + + fn append(&self, source: S) + where + S: Source + Send + 'static, + { + self.player + .append(NativeSpatialSource::new(source, Arc::clone(&self.state))); + } + + #[cfg(test)] + fn volume(&self) -> f32 { + self.state.lock().expect("audio state poisoned").volume + } + + fn set_volume(&self, volume: f32) { + self.state.lock().expect("audio state poisoned").volume = volume; + } + + fn set_speed(&self, speed: f32) { + self.player.set_speed(speed); + } + + fn play(&self) { + self.player.play(); + } + + fn pause(&self) { + self.player.pause(); + } + + #[cfg(test)] + fn is_paused(&self) -> bool { + self.player.is_paused() + } + + fn clear(&self) { + self.player.clear(); + } + + fn stop(&self) { + self.player.stop(); + } + + fn empty(&self) -> bool { + self.player.empty() + } +} + +struct NativeSpatialSource +where + I: Source, +{ + input: ChannelVolume, + state: Arc>, + channel_gains: [f32; 2], + refresh_remaining: usize, +} + +const NATIVE_SPATIAL_REFRESH_SAMPLES: usize = 256; + +impl NativeSpatialSource +where + I: Source, +{ + fn new(input: I, state: Arc>) -> Self { + Self { + input: ChannelVolume::new(input, vec![0.0, 0.0]), + state, + channel_gains: [0.0, 0.0], + refresh_remaining: 0, + } + } +} + +impl Iterator for NativeSpatialSource +where + I: Source, +{ + type Item = f32; + + fn next(&mut self) -> Option { + if self.refresh_remaining == 0 { + if let Ok(state) = self.state.lock() { + self.channel_gains = native_spatial_channel_gains(*state); + } + self.refresh_remaining = NATIVE_SPATIAL_REFRESH_SAMPLES; + } + self.refresh_remaining -= 1; + self.input.set_volume(0, self.channel_gains[0]); + self.input.set_volume(1, self.channel_gains[1]); + self.input.next() + } +} + +impl Source for NativeSpatialSource +where + I: Source, +{ + fn current_span_len(&self) -> Option { + self.input.current_span_len() + } + + fn channels(&self) -> ChannelCount { + self.input.channels() + } + + fn sample_rate(&self) -> SampleRate { + self.input.sample_rate() + } + + fn total_duration(&self) -> Option { + self.input.total_duration() + } + + fn try_seek(&mut self, position: std::time::Duration) -> Result<(), SeekError> { + self.input.try_seek(position) + } +} + +impl AudioLibrary { + fn load(vfs: Arc, mission_cfg: &NormalizedPath) -> Result { + let repository = Arc::new(CachedResourceRepository::new(Arc::clone(&vfs))); + let cfg_bytes = vfs + .read(mission_cfg) + .map_err(|source| AudioError::MissionConfigRead { + path: mission_cfg.as_str().to_string(), + message: source.to_string(), + })?; + let config = parse_mission_audio_config(&cfg_bytes).map_err(|message| { + AudioError::MissionConfig { + path: mission_cfg.as_str().to_string(), + message, + } + })?; + let archive = + archive_path(config.library.as_bytes()).map_err(|source| AudioError::InvalidPath { + path: config.library.clone(), + message: source.to_string(), + })?; + let variation_archive = + archive_path(config.variation_library.as_bytes()).map_err(|source| { + AudioError::InvalidPath { + path: config.variation_library.clone(), + message: source.to_string(), + } + })?; + + let theme = load_swav(&repository, &archive, &config.theme)?; + let default_variations = config + .default_variations + .iter() + .map(|name| load_swav(&repository, &variation_archive, name)) + .collect::, _>>()?; + let day_variations = config + .day_variations + .iter() + .map(|name| load_swav(&repository, &variation_archive, name)) + .collect::, _>>()?; + let night_variations = config + .night_variations + .iter() + .map(|name| load_swav(&repository, &variation_archive, name)) + .collect::, _>>()?; + Ok(Self { + vfs, + repository, + theme, + default_variations, + day_variations, + night_variations, + music: BTreeMap::new(), + }) + } + + fn load_fx_sound(&self, archive: &str, name: &str) -> Result { + let archive = + archive_path(archive.as_bytes()).map_err(|source| AudioError::InvalidPath { + path: archive.to_string(), + message: source.to_string(), + })?; + load_swav(&self.repository, &archive, name) + } + + fn music_bytes(&mut self, track: u8) -> Result, AudioError> { + let path = music_path(track)?; + if let Some(bytes) = self.music.get(&track) { + return Ok(Arc::clone(bytes)); + } + let bytes = self + .vfs + .read(&path) + .map_err(|source| AudioError::FileRead { + path: path.as_str().to_string(), + message: source.to_string(), + })?; + let bytes = validate_audio_bytes(path.as_str(), &bytes)?; + self.music.insert(track, Arc::clone(&bytes)); + Ok(bytes) + } +} + +/// Runtime audio state for one loaded mission. +pub(crate) struct GameAudio { + stream: MixerDeviceSink, + ambient: Player, + music: Player, + variations: Player, + weather_loop: NativeSpatialPlayer, + one_shots: Vec, + library: AudioLibrary, + ambience: AmbienceScheduler, + listener: Listener, + paused: bool, + dynamic_sounds: BTreeMap, + weather_volume: f32, + weather_position: [f32; 3], +} + +impl GameAudio { + /// Opens and validates mission audio using the caller's VFS. + /// + /// mission is the same relative path passed to the runtime, usually + /// MISSIONS/Autodemo.00/data.tma. Its neighbouring mission.cfg supplies + /// the ambient names. Only the declared samples and selected music tracks + /// are read. + /// + /// Returns a resource/configuration error for missing or malformed audio, + /// or DeviceUnavailable when no output device can be opened. The caller + /// may report the latter and continue without audio. + pub(crate) fn new(vfs: Arc, mission: &str) -> Result { + let mission_cfg = mission_cfg_path(mission)?; + let library = AudioLibrary::load(vfs, &mission_cfg)?; + let mut stream = DeviceSinkBuilder::open_default_sink().map_err(|source| { + AudioError::DeviceUnavailable { + message: source.to_string(), + } + })?; + stream.log_on_drop(false); + let ambient = Player::connect_new(stream.mixer()); + let music = Player::connect_new(stream.mixer()); + let variations = Player::connect_new(stream.mixer()); + let listener = Listener::default(); + let (left, right) = ear_positions(listener)?; + let weather_loop = + NativeSpatialPlayer::connect_new(stream.mixer(), listener.position, left, right); + let mut audio = Self { + stream, + ambient, + music, + variations, + weather_loop, + one_shots: Vec::new(), + library, + ambience: AmbienceScheduler::new(random_seed(), random_seed()), + listener, + paused: false, + dynamic_sounds: BTreeMap::new(), + weather_volume: 1.0, + weather_position: listener.position, + }; + audio.start_theme()?; + audio.play_music_track(DEFAULT_MUSIC_TRACK)?; + Ok(audio) + } + + /// Updates rodio's left/right ears from the current free camera. + pub(crate) fn update_listener( + &mut self, + position: [f32; 3], + forward: [f32; 3], + up: [f32; 3], + ) -> Result<(), AudioError> { + let listener = Listener { + position, + forward, + up, + }; + let (left, right) = ear_positions(listener)?; + self.listener = listener; + self.weather_loop.set_left_ear_position(left); + self.weather_loop.set_right_ear_position(right); + self.weather_loop + .set_emitter_position(self.weather_position); + self.retain_active_one_shots(); + for one_shot in &self.one_shots { + one_shot.player.set_left_ear_position(left); + one_shot.player.set_right_ear_position(right); + one_shot.player.set_volume(one_shot.volume); + } + self.refresh_weather_volume(); + Ok(()) + } + + /// Advances the native ambient variation timer and starts a selected + /// sample when its strict elapsed-time boundary is crossed. + /// + /// `dt_seconds` is the non-negative delta for this frame, not an + /// absolute mission clock. + /// + /// `primary_rgb` is the first active celestial light's base color before + /// any camera-dependent sun glare is applied. The original atmosphere + /// treats a color length at or below 1.1 as night. + pub(crate) fn update_ambience( + &mut self, + dt_seconds: f32, + primary_rgb: [f32; 3], + ) -> Result<(), AudioError> { + if self.paused { + return Ok(()); + } + let selection = self.ambience.update( + dt_seconds, + primary_rgb, + self.library.default_variations.len(), + self.library.day_variations.len(), + self.library.night_variations.len(), + ); + let Some(selection) = selection else { + return Ok(()); + }; + let Some(index) = selection.index else { + return Ok(()); + }; + let asset = match selection.pool { + VariationPool::Default => self.library.default_variations.get(index), + VariationPool::Day => self.library.day_variations.get(index), + VariationPool::Night => self.library.night_variations.get(index), + } + .cloned() + .ok_or_else(|| AudioError::NoSamples { + resource: format!("ambient variation {:?}[{index}]", selection.pool), + })?; + self.variations.clear(); + self.variations.append(decoder_for(&asset)?); + if self.paused { + self.variations.pause(); + } else { + self.variations.play(); + } + Ok(()) + } + + /// Applies one sound event emitted by the environment simulation. + /// + /// Resource names are resolved lazily from the archive named by the event; + /// this keeps mission `sky.ske`/FX references data-driven and avoids + /// requiring every possible weather sample during mission startup. + pub(crate) fn handle_sound_event(&mut self, event: &SoundEvent) -> Result<(), AudioError> { + validate_point(event.position, "emitter")?; + let volume = if event.volume.is_finite() { + event.volume.max(0.0) + } else { + 0.0 + }; + let min_distance = finite_non_negative(event.min_distance); + let max_distance = if event.max_distance.is_finite() { + event.max_distance.max(0.0) + } else { + f32::INFINITY + }; + 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 => { + let asset = self.dynamic_sound(&event.archive, &event.name)?; + self.weather_loop.clear(); + self.weather_loop.set_emitter_position(event.position); + self.weather_position = event.position; + self.weather_volume = volume; + self.weather_loop + .set_sound_parameters(min_distance, max_distance); + self.weather_loop.set_volume(volume); + self.weather_loop.set_speed(frequency_ratio); + self.weather_loop + .append(decoder_for(&asset)?.repeat_infinite()); + if self.paused { + self.weather_loop.pause(); + } else { + self.weather_loop.play(); + } + } + SoundAction::SetLoopVolume => { + self.weather_loop.set_emitter_position(event.position); + self.weather_position = event.position; + self.weather_volume = volume; + self.weather_loop + .set_sound_parameters(min_distance, max_distance); + self.weather_loop.set_volume(volume); + self.weather_loop.set_speed(frequency_ratio); + } + SoundAction::StopLoop => { + self.weather_loop.clear(); + self.weather_loop.set_volume(1.0); + self.weather_volume = 1.0; + self.weather_position = self.listener.position; + self.weather_loop.set_sound_parameters(0.0, f32::INFINITY); + self.weather_loop.set_speed(1.0); + } + 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( + &asset, + event.position, + volume, + min_distance, + max_distance, + frequency_ratio, + )?); + } + } + Ok(()) + } + + /// 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)?; + self.music.clear(); + self.music.append( + decoder_for_bytes(&format!("MUSIC/Track{track:02}.ogg"), &bytes)?.repeat_infinite(), + ); + if self.paused { + self.music.pause(); + } else { + self.music.play(); + } + Ok(()) + } + + /// Pauses or resumes every live audio source without changing selection. + pub(crate) fn set_paused(&mut self, paused: bool) { + self.paused = paused; + for player in [&self.ambient, &self.music, &self.variations] { + if paused { + player.pause(); + } else { + player.play(); + } + } + if paused { + self.weather_loop.pause(); + } else { + self.weather_loop.play(); + } + for one_shot in &self.one_shots { + if paused { + one_shot.player.pause(); + } else { + one_shot.player.play(); + } + } + } + + /// Focus callback used by the window event loop. + pub(crate) fn on_focus_changed(&mut self, focused: bool) { + self.set_paused(!focused); + } + + /// Stops all sources before the window/event loop exits. + pub(crate) fn shutdown(&mut self) { + self.ambient.stop(); + self.music.stop(); + self.variations.stop(); + self.weather_loop.stop(); + for one_shot in &self.one_shots { + one_shot.player.stop(); + } + } + + fn start_theme(&self) -> Result<(), AudioError> { + self.ambient + .append(decoder_for(&self.library.theme)?.repeat_infinite()); + if self.paused { + self.ambient.pause(); + } else { + self.ambient.play(); + } + Ok(()) + } + + fn new_spatial_one_shot( + &self, + asset: &SoundAsset, + emitter: [f32; 3], + volume: f32, + min_distance: f32, + max_distance: f32, + frequency_ratio: f32, + ) -> Result { + validate_point(emitter, "emitter")?; + let (left, right) = ear_positions(self.listener)?; + let player = NativeSpatialPlayer::connect_new(self.stream.mixer(), emitter, left, right); + player.set_sound_parameters(min_distance, max_distance); + player.set_volume(volume); + player.set_speed(frequency_ratio); + player.append(decoder_for(asset)?); + if self.paused { + player.pause(); + } else { + player.play(); + } + Ok(SpatialOneShot { player, volume }) + } + + fn refresh_weather_volume(&self) { + self.weather_loop.set_volume(self.weather_volume); + } + + fn dynamic_sound(&mut self, archive: &str, name: &str) -> Result { + let archive = if archive.is_empty() { + self.library.theme.archive.as_str() + } else { + archive + }; + let key = format!( + "{}:{}", + archive.to_ascii_lowercase(), + name.to_ascii_lowercase() + ); + if let Some(asset) = self.dynamic_sounds.get(&key) { + return Ok(asset.clone()); + } + let asset = self.library.load_fx_sound(archive, name)?; + self.dynamic_sounds.insert(key, asset.clone()); + Ok(asset) + } + + fn retain_active_one_shots(&mut self) { + self.one_shots.retain(|one_shot| !one_shot.player.empty()); + } +} + +impl Drop for GameAudio { + fn drop(&mut self) { + self.shutdown(); + } +} + +type AudioDecoder = Decoder>>; + +fn decoder_for(asset: &SoundAsset) -> Result { + decoder_for_bytes(&asset.resource_name(), &asset.bytes) +} + +fn decoder_for_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result { + Decoder::try_from(Cursor::new(Arc::clone(bytes))).map_err(|source| AudioError::Decode { + resource: resource.to_string(), + message: source.to_string(), + }) +} + +fn validate_audio_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result, AudioError> { + if bytes.is_empty() { + return Err(AudioError::EmptyEntry { + archive: "VFS".to_string(), + name: resource.to_string(), + }); + } + let decoder = decoder_for_bytes(resource, bytes)?; + if decoder.take(VALIDATION_SAMPLES).count() == 0 { + return Err(AudioError::NoSamples { + resource: resource.to_string(), + }); + } + Ok(Arc::clone(bytes)) +} + +fn load_swav( + repository: &CachedResourceRepository, + archive: &NormalizedPath, + name: &str, +) -> Result { + let display_archive = archive.as_str().to_string(); + let resource = resource_name(name.as_bytes()); + let archive_id = repository + .open_archive_unchanged(archive) + .map_err(|source| AudioError::Archive { + path: display_archive.clone(), + message: source.to_string(), + })?; + let entry = repository + .find(archive_id, &resource) + .map_err(|source| AudioError::Archive { + path: display_archive.clone(), + message: source.to_string(), + })? + .ok_or_else(|| AudioError::MissingEntry { + archive: display_archive.clone(), + name: name.to_string(), + })?; + let info = repository + .entry_info(entry) + .map_err(|source| AudioError::Archive { + path: display_archive.clone(), + message: source.to_string(), + })?; + if info.key.type_id != Some(SWAV_TYPE) { + return Err(AudioError::WrongEntryType { + archive: display_archive, + name: name.to_string(), + type_id: info.key.type_id, + }); + } + let bytes = repository + .read(entry) + .map_err(|source| AudioError::Archive { + path: archive.as_str().to_string(), + message: source.to_string(), + })?; + if bytes.is_empty() { + return Err(AudioError::EmptyEntry { + archive: archive.as_str().to_string(), + name: name.to_string(), + }); + } + let bytes = Arc::<[u8]>::from(bytes.as_slice()); + let bytes = validate_audio_bytes(&format!("{}:{name}", archive.as_str()), &bytes)?; + Ok(SoundAsset { + archive: archive.as_str().to_string(), + name: name.to_string(), + bytes, + }) +} + +fn music_path(track: u8) -> Result { + if !(FIRST_MUSIC_TRACK..=LAST_MUSIC_TRACK).contains(&track) { + return Err(AudioError::UnsupportedMusicTrack(track)); + } + let display = format!("MUSIC/Track{track:02}.ogg"); + normalize_relative(display.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + AudioError::InvalidPath { + path: display, + message: source.to_string(), + } + }) +} + +/// Derives the sibling mission.cfg path from a mission TMA path. +pub(crate) fn mission_cfg_path(mission: &str) -> Result { + let mission = + normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + AudioError::InvalidPath { + path: mission.to_string(), + message: source.to_string(), + } + })?; + let cfg = mission.as_str().rsplit_once('/').map_or_else( + || "mission.cfg".to_string(), + |(parent, _)| format!("{parent}/mission.cfg"), + ); + normalize_relative(cfg.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + AudioError::InvalidPath { + path: cfg, + message: source.to_string(), + } + }) +} + +fn ear_positions(listener: Listener) -> Result<([f32; 3], [f32; 3]), AudioError> { + validate_point(listener.position, "position")?; + let forward = unit(listener.forward, "forward")?; + let up = unit(listener.up, "up")?; + let right = unit(cross(forward, up), "forward/up")?; + let left_ear = sub(listener.position, scale(right, EAR_HALF_WIDTH)); + let right_ear = add(listener.position, scale(right, EAR_HALF_WIDTH)); + validate_point(left_ear, "position")?; + validate_point(right_ear, "position")?; + Ok((left_ear, right_ear)) +} + +const DIRECTSOUND_DB_COEFFICIENT: f32 = 3_321.927_978_515_625; +const DIRECTSOUND_MIN_VOLUME_DB: f32 = -10_000.0; +const DIRECTSOUND_PAN_UNITS: f32 = 4_000.0; + +/// Converts a DirectSound distance range into a linear sample multiplier. +/// The native listener uses rolloff 1.0 and stores volume in hundredths of a +/// decibel. Its max-distance branch is strict (`distance > max`). +fn native_distance_gain(distance: f32, min_distance: f32, max_distance: f32) -> f32 { + if !distance.is_finite() + || !min_distance.is_finite() + || min_distance <= 0.0 + || !max_distance.is_finite() + || max_distance <= min_distance + { + return 1.0; + } + if distance <= min_distance { + return 1.0; + } + if distance > max_distance { + return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0); + } + let ratio = min_distance / (min_distance + (distance - min_distance)); + if !ratio.is_finite() || ratio <= 0.0 { + return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0); + } + let decibels = (ratio.log10() * DIRECTSOUND_DB_COEFFICIENT) + .round() + .max(DIRECTSOUND_MIN_VOLUME_DB); + 10.0_f32.powf(decibels / 2_000.0) +} + +fn native_spatial_channel_gains(state: NativeSpatialState) -> [f32; 2] { + let listener_position = midpoint(state.left_ear, state.right_ear); + let distance = squared_distance(state.emitter_position, listener_position).sqrt(); + let attenuation = native_distance_gain(distance, state.min_distance, state.max_distance); + // Effect.dll computes software pan from the listener's right vector and + // the normalized emitter direction, then passes +/-4000 to DirectSound. + // The engine's ear positions already encode that right vector. Do not use + // the distance to each ear: that heuristic reverses the native pan when a + // source is placed to the listener's right. + let ear_axis = sub(state.right_ear, state.left_ear); + let ear_axis_length = squared_distance(ear_axis, [0.0; 3]).sqrt(); + let emitter_direction = sub(state.emitter_position, listener_position); + let emitter_length = squared_distance(emitter_direction, [0.0; 3]).sqrt(); + let pan = if ear_axis_length.is_finite() + && ear_axis_length > f32::EPSILON + && emitter_length.is_finite() + && emitter_length > f32::EPSILON + { + let right = scale(ear_axis, 1.0 / ear_axis_length); + let direction = scale(emitter_direction, 1.0 / emitter_length); + (DIRECTSOUND_PAN_UNITS * dot(right, direction)).round() + } else { + 0.0 + }; + let (left_pan, right_pan) = if pan > 0.0 { + (10.0_f32.powf(-pan / 2_000.0), 1.0) + } else if pan < 0.0 { + (1.0, 10.0_f32.powf(pan / 2_000.0)) + } else { + (1.0, 1.0) + }; + let volume = if state.volume.is_finite() { + state.volume.max(0.0) + } else { + 0.0 + } * attenuation; + [volume * left_pan, volume * right_pan] +} + +fn dot(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2])) +} + +fn squared_distance(left: [f32; 3], right: [f32; 3]) -> f32 { + left.into_iter() + .zip(right) + .map(|(left, right)| (left - right) * (left - right)) + .sum() +} + +fn midpoint(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + (left[0] + right[0]) * 0.5, + (left[1] + right[1]) * 0.5, + (left[2] + right[2]) * 0.5, + ] +} + +fn validate_point(point: [f32; 3], field: &'static str) -> Result<(), AudioError> { + if point.iter().all(|value| value.is_finite()) { + Ok(()) + } else { + Err(AudioError::InvalidPosition { field }) + } +} + +fn unit(vector: [f32; 3], field: &'static str) -> Result<[f32; 3], AudioError> { + if !vector.iter().all(|value| value.is_finite()) { + return Err(AudioError::InvalidListener { field }); + } + let length = vector[0] + .mul_add( + vector[0], + vector[1].mul_add(vector[1], vector[2] * vector[2]), + ) + .sqrt(); + if !length.is_finite() || length <= f32::EPSILON { + return Err(AudioError::InvalidListener { field }); + } + Ok(vector.map(|value| value / length)) +} + +fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn add(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +fn sub(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn scale(vector: [f32; 3], factor: f32) -> [f32; 3] { + [vector[0] * factor, vector[1] * factor, vector[2] * factor] +} + +fn parse_mission_audio_config(bytes: &[u8]) -> Result { + #[derive(Default)] + struct Object { + library: Option, + theme: Option, + default: BTreeMap, + day: BTreeMap, + night: BTreeMap, + } + + let text = String::from_utf8_lossy(bytes); + let mut loop_object = Object::default(); + let mut variation_object = Object::default(); + let mut current = None; + for raw_line in text.lines() { + let line = raw_line.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + let mut tokens = line.split_whitespace(); + if tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("object")) + { + current = tokens.next().map(|name| name.to_ascii_lowercase()); + continue; + } + if line.eq_ignore_ascii_case("end") { + current = None; + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim().to_ascii_uppercase(); + let value = parse_cfg_value(value.trim())?; + match current.as_deref() { + Some("ambient_music_loop") => { + if key == "LIBRARY" { + loop_object.library = Some(value); + } else if key == "THEME" { + loop_object.theme = Some(value); + } + } + Some("ambient_music_variation") => { + if key == "LIBRARY" { + variation_object.library = Some(value); + } else if let Some(index) = key.strip_prefix("DEFAULT_VARIATION") { + let index = index + .parse::() + .map_err(|_| format!("invalid default variation key {key}"))?; + if index == 0 { + return Err(format!("zero default variation key {key}")); + } + variation_object.default.insert(index, value); + } else if let Some(index) = key.strip_prefix("DAY_VARIATION") { + let index = index + .parse::() + .map_err(|_| format!("invalid day variation key {key}"))?; + if index == 0 { + return Err(format!("zero day variation key {key}")); + } + variation_object.day.insert(index, value); + } else if let Some(index) = key.strip_prefix("NIGHT_VARIATION") { + let index = index + .parse::() + .map_err(|_| format!("invalid night variation key {key}"))?; + if index == 0 { + return Err(format!("zero night variation key {key}")); + } + variation_object.night.insert(index, value); + } + } + _ => {} + } + } + let library = loop_object + .library + .ok_or_else(|| "ambient_music_loop.library is missing".to_string())?; + let theme = loop_object + .theme + .ok_or_else(|| "ambient_music_loop.THEME is missing".to_string())?; + let variation_library = variation_object.library.unwrap_or_else(|| library.clone()); + Ok(MissionAudioConfig { + library, + variation_library, + theme, + default_variations: variation_object.default.into_values().collect(), + day_variations: variation_object.day.into_values().collect(), + night_variations: variation_object.night.into_values().collect(), + }) +} + +fn parse_cfg_value(value: &str) -> Result { + if let Some(rest) = value.strip_prefix('"') { + let end = rest + .find('"') + .ok_or_else(|| "unterminated quoted value".to_string())?; + return Ok(rest[..end].to_string()); + } + Ok(value + .split_whitespace() + .next() + .unwrap_or_default() + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_original_ambient_names_in_numeric_order() { + let config = parse_mission_audio_config( + br#" + object ambient_music_loop + library = "sounds.lib" + THEME = "atm_c1_lp.wav" + end + object ambient_music_variation + library = "ambient.lib" + DEFAULT_VARIATION2 = "atm_default2.wav" + DEFAULT_VARIATION1 = "atm_default1.wav" + DAY_VARIATION2 = "atm_bird2.wav" + DAY_VARIATION1 = "atm_bees.wav" + NIGHT_VARIATION2 = "atm_frog2.wav" + NIGHT_VARIATION1 = "atm_frog1.wav" + end + "#, + ) + .expect("config"); + assert_eq!(config.library, "sounds.lib"); + assert_eq!(config.variation_library, "ambient.lib"); + assert_eq!(config.theme, "atm_c1_lp.wav"); + assert_eq!( + config.default_variations, + ["atm_default1.wav", "atm_default2.wav"] + ); + assert_eq!(config.day_variations, ["atm_bees.wav", "atm_bird2.wav"]); + assert_eq!(config.night_variations, ["atm_frog1.wav", "atm_frog2.wav"]); + } + + #[test] + fn omitted_variation_library_uses_loop_library() { + let config = parse_mission_audio_config( + br#" + object ambient_music_loop + library = "sounds.lib" + THEME = "atm_c1_lp.wav" + end + object ambient_music_variation + DEFAULT_VARIATION1 = "atm_c1_1.wav" + end + "#, + ) + .expect("config"); + assert_eq!(config.variation_library, "sounds.lib"); + assert_eq!(config.default_variations, ["atm_c1_1.wav"]); + } + + #[test] + fn duplicate_native_variation_key_keeps_last_definition() { + let config = parse_mission_audio_config( + br#" + object ambient_music_loop + library = "sounds.lib" + THEME = "atm_c1_lp.wav" + end + object ambient_music_variation + DAY_VARIATION1 = "old.wav" + DAY_VARIATION1 = "native-last.wav" + end + "#, + ) + .expect("config"); + assert_eq!(config.day_variations, ["native-last.wav"]); + } + + #[test] + fn variation_rng_matches_native_two_state_step() { + let mut rng = VariationRng { + a: 0x1234, + b: 0xabcd, + }; + rng.step(); + assert_eq!((rng.a, rng.b), (0x8fa5, 0xda43)); + rng.step(); + assert_eq!((rng.a, rng.b), (0xc509, 0xa828)); + rng.step(); + assert_eq!((rng.a, rng.b), (0x223a, 0x762e)); + } + + #[test] + fn variation_rng_does_not_repeat_last_index_when_pool_has_choices() { + let mut rng = VariationRng { + a: 0x1234, + b: 0xabcd, + }; + let mut last = -1; + let mut previous = None; + for _ in 0..64 { + let selected = rng.choose(4, &mut last).expect("non-empty pool"); + assert_ne!(Some(selected), previous); + previous = Some(selected); + } + } + + #[test] + fn variation_rng_clears_last_index_for_empty_pool() { + let mut rng = VariationRng { + a: 0x1234, + b: 0xabcd, + }; + let mut last = 2; + assert_eq!(rng.choose(0, &mut last), None); + assert_eq!(last, -1); + assert_eq!(rng.choose(3, &mut last), Some(0)); + assert_eq!(last, 0); + } + + #[test] + fn crt_delay_uses_native_multiplier_and_ten_to_nineteen_seconds() { + let mut rng = CrtRand::new(1); + assert_eq!(rng.next_u15(), 41); + assert_eq!(rng.next_delay_seconds(), 17.0); + assert!((10.0..20.0).contains(&rng.next_delay_seconds())); + } + + #[test] + fn ambience_timer_uses_strict_boundary_and_initial_positive_tick() { + let mut scheduler = AmbienceScheduler { + elapsed_seconds: 0.0, + next_delay_seconds: 0.0, + pool: VariationPool::Default, + variation_rng: VariationRng { + a: 0x1234, + b: 0xabcd, + }, + delay_rng: CrtRand::new(16), + last_index: -1, + }; + assert_eq!(scheduler.update(0.0, [2.0, 0.0, 0.0], 0, 1, 1), None); + let initial = scheduler + .update(0.001, [2.0, 0.0, 0.0], 0, 1, 1) + .expect("first positive tick selects a variation"); + assert_eq!(initial.pool, VariationPool::Day); + assert_eq!(initial.index, Some(0)); + assert_eq!(scheduler.next_delay_seconds, 10.0); + assert_eq!(scheduler.update(10.0, [2.0, 0.0, 0.0], 0, 1, 1), None); + assert!(scheduler.update(0.0001, [2.0, 0.0, 0.0], 0, 1, 1).is_some()); + } + + #[test] + fn ambience_pool_selection_has_no_cross_day_fallback() { + let mut scheduler = AmbienceScheduler::new(1, 1); + let selected = scheduler + .update(0.01, [0.0, 0.0, 0.0], 2, 1, 0) + .expect("night selection attempt"); + assert_eq!(selected.pool, VariationPool::Night); + assert_eq!(selected.index, None); + + let mut scheduler = AmbienceScheduler::new(1, 1); + let selected = scheduler + .update(0.01, [2.0, 0.0, 0.0], 2, 0, 0) + .expect("default selection"); + assert_eq!(selected.pool, VariationPool::Default); + assert_eq!(selected.index, Some(0)); + } + + #[test] + fn night_threshold_is_native_primary_rgb_length() { + assert_eq!(selected_pool([1.1, 0.0, 0.0], 1, 1), VariationPool::Night); + assert_eq!(selected_pool([1.1001, 0.0, 0.0], 1, 1), VariationPool::Day); + assert_eq!(selected_pool([0.0, 0.0, 0.0], 0, 0), VariationPool::Default); + } + + #[test] + fn derives_sibling_mission_config_path() { + assert_eq!( + mission_cfg_path("MISSIONS/Single.01/data.tma") + .expect("mission path") + .as_str(), + "MISSIONS/Single.01/mission.cfg" + ); + } + + #[test] + fn listener_ears_are_spaced_across_camera_right() { + let (left, right) = ear_positions(Listener::default()).expect("ears"); + assert_eq!(left, [-EAR_HALF_WIDTH, 0.0, 0.0]); + assert_eq!(right, [EAR_HALF_WIDTH, 0.0, 0.0]); + } + + #[test] + fn native_pan_uses_listener_right_axis_and_directsound_units() { + let state = NativeSpatialState { + emitter_position: [0.0, 0.0, 0.0], + left_ear: [-EAR_HALF_WIDTH, 0.0, 0.0], + right_ear: [EAR_HALF_WIDTH, 0.0, 0.0], + volume: 1.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + }; + assert_eq!(native_spatial_channel_gains(state), [1.0, 1.0]); + + let right = NativeSpatialState { + emitter_position: [1.0, 0.0, 0.0], + ..state + }; + let right_gains = native_spatial_channel_gains(right); + assert!((right_gains[0] - 0.01).abs() < 1.0e-6); + assert_eq!(right_gains[1], 1.0); + + let left = NativeSpatialState { + emitter_position: [-1.0, 0.0, 0.0], + ..state + }; + let left_gains = native_spatial_channel_gains(left); + assert_eq!(left_gains[0], 1.0); + assert!((left_gains[1] - 0.01).abs() < 1.0e-6); + } + + #[test] + fn native_distance_gain_keeps_min_and_max_boundaries_native() { + let below_min = native_distance_gain(99.0, 100.0, 1_500.0); + let at_min = native_distance_gain(100.0, 100.0, 1_500.0); + let at_max = native_distance_gain(1_500.0, 100.0, 1_500.0); + let past_max = native_distance_gain(1_500.001, 100.0, 1_500.0); + + assert_eq!(below_min, 1.0); + assert_eq!(at_min, 1.0); + assert!(at_max > past_max); + assert!(at_max < 1.0); + assert!((past_max - 1.0e-5).abs() < 1.0e-8); + } + + #[test] + fn rejects_non_finite_or_unbounded_listener_inputs() { + assert!(ear_positions(Listener { + position: [f32::NAN, 0.0, 0.0], + ..Listener::default() + }) + .is_err()); + assert!(ear_positions(Listener { + forward: [f32::MAX, f32::MAX, f32::MAX], + ..Listener::default() + }) + .is_err()); + } + + #[test] + #[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"] + fn decodes_gog_pcm_adpcm_and_ogg_assets() { + 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")); + assert!( + root.is_dir(), + "game root does not exist: {}", + root.display() + ); + let vfs: Arc = Arc::new(fparkan_vfs::DirectoryVfs::new(&root)); + let mission_cfg = normalize_relative( + b"MISSIONS/Autodemo.00/mission.cfg", + PathPolicy::StrictLegacy, + ) + .expect("mission cfg path"); + let mut library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg).expect("audio assets"); + assert_eq!(library.theme.name, "atm_c1_lp.wav"); + assert!(!library.day_variations.is_empty()); + assert!(!library.night_variations.is_empty()); + assert_eq!(wav_format_tag(&library.theme.bytes), Some(1)); + assert_decodes_samples(&library.theme.resource_name(), &library.theme.bytes, 256); + + let adpcm = library + .day_variations + .iter() + .find(|asset| wav_format_tag(&asset.bytes) == Some(2)) + .expect("an original day variation must use Microsoft ADPCM"); + assert_eq!(wav_format_tag(&adpcm.bytes), Some(2)); + assert_decodes_samples(&adpcm.resource_name(), &adpcm.bytes, 256); + + let music = library + .music_bytes(DEFAULT_MUSIC_TRACK) + .expect("original Track02 OGG"); + assert!(music.starts_with(b"OggS")); + assert_decodes_samples("MUSIC/Track02.ogg", &music, 256); + assert_eq!(library.music.len(), 1, "music must load lazily"); + } + + #[test] + #[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"] + fn loads_every_gog_mission_variation_pool() { + 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")); + assert!( + root.is_dir(), + "game root does not exist: {}", + root.display() + ); + let mut paths = Vec::new(); + collect_mission_configs(&root, &mut paths).expect("enumerate mission configs"); + assert!(!paths.is_empty(), "GOG install has no mission.cfg files"); + let vfs: Arc = Arc::new(fparkan_vfs::DirectoryVfs::new(&root)); + let mut default_pools = 0; + let mut day_pools = 0; + let mut night_pools = 0; + for path in paths { + let relative = path + .strip_prefix(&root) + .expect("mission config under game root") + .to_string_lossy() + .replace('\\', "/"); + let mission_cfg = normalize_relative(relative.as_bytes(), PathPolicy::StrictLegacy) + .expect("mission cfg path"); + let library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg) + .unwrap_or_else(|error| panic!("load {}: {error}", mission_cfg.as_str())); + default_pools += usize::from(!library.default_variations.is_empty()); + day_pools += usize::from(!library.day_variations.is_empty()); + night_pools += usize::from(!library.night_variations.is_empty()); + } + assert!(default_pools > 0, "GOG missions must exercise DEFAULT pool"); + assert!(day_pools > 0, "GOG missions must exercise DAY pool"); + assert!(night_pools > 0, "GOG missions must exercise NIGHT pool"); + } + + #[test] + #[ignore = "requires installed GOG assets and a usable OS audio output"] + fn starts_pauses_and_drops_gog_audio() { + 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")); + assert!( + root.is_dir(), + "game root does not exist: {}", + root.display() + ); + let vfs: Arc = Arc::new(fparkan_vfs::DirectoryVfs::new(&root)); + let mut audio = + GameAudio::new(vfs, "MISSIONS/Autodemo.00/data.tma").expect("open original game audio"); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_rain1.wav".to_string(), + position: [0.0; 3], + volume: 0.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::StartLoop, + }) + .expect("start original rain loop"); + assert_eq!(audio.weather_loop.volume(), 0.0); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_rain1.wav".to_string(), + position: [1.0, 2.0, 3.0], + volume: 0.5, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::SetLoopVolume, + }) + .expect("update original rain loop volume"); + assert_eq!(audio.weather_loop.volume(), 0.5); + audio + .update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]) + .expect("move listener"); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_light1.wav".to_string(), + position: [3.0, 4.0, 5.0], + volume: 0.75, + min_distance: 100.0, + max_distance: 1500.0, + frequency_ratio: 1.0, + action: SoundAction::OneShot, + }) + .expect("play original lightning sample"); + assert_eq!( + audio + .one_shots + .last() + .unwrap() + .player + .state + .lock() + .unwrap() + .emitter_position, + [3.0, 4.0, 5.0] + ); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_rain1.wav".to_string(), + position: [10.0, 20.0, 30.0], + volume: 0.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::StopLoop, + }) + .expect("stop original rain loop"); + assert!(audio.weather_loop.empty()); + audio.set_paused(true); + assert!(audio.ambient.is_paused()); + assert!(audio.music.is_paused()); + assert!(audio.weather_loop.is_paused()); + audio.set_paused(false); + assert!(!audio.ambient.is_paused()); + assert!(!audio.music.is_paused()); + audio.shutdown(); + } + + fn assert_decodes_samples(resource: &str, bytes: &Arc<[u8]>, minimum: usize) { + let decoded = decoder_for_bytes(resource, bytes) + .expect("decoder construction") + .take(minimum) + .count(); + assert_eq!(decoded, minimum, "{resource} decoded too few samples"); + } + + fn wav_format_tag(bytes: &[u8]) -> Option { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let mut offset = 12usize; + while offset.checked_add(8)? <= bytes.len() { + let chunk_len = usize::try_from(u32::from_le_bytes( + bytes[offset + 4..offset + 8].try_into().ok()?, + )) + .ok()?; + let data_start = offset.checked_add(8)?; + let data_end = data_start.checked_add(chunk_len)?; + if data_end > bytes.len() { + return None; + } + if &bytes[offset..offset + 4] == b"fmt " && chunk_len >= 2 { + return Some(u16::from_le_bytes( + bytes[data_start..data_start + 2].try_into().ok()?, + )); + } + offset = data_end.checked_add(chunk_len & 1)?; + } + None + } + + fn collect_mission_configs( + directory: &std::path::Path, + output: &mut Vec, + ) -> std::io::Result<()> { + for entry in std::fs::read_dir(directory)? { + let path = entry?.path(); + if path.is_dir() { + collect_mission_configs(&path, output)?; + } else if path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("mission.cfg")) + { + output.push(path); + } + } + Ok(()) + } +} diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 2130bf5..7405458 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -2,40 +2,75 @@ //! `FParkan` render-planning composition root. +use fparkan_animation::{AnimationTime, NodePoseBuffer}; use fparkan_assets::{ - load_standalone_wear_material_texture_mip0_rgba8_from_root, PreparedTextureUsage, - PreparedVisual, + 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_material_phase, Mat0Document, MaterialPhase, MaterialPhaseSample, + PreparedControlNodeBinding, PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, + PreparedVisual, RgbaImage, StandaloneWearMaterialLoader, +}; +use fparkan_fx::atmosphere::TypedAtmosphere; +use fparkan_fx::environment::{ + decode_env_lightning_fxid, Camera as FxCamera, EnvironmentFrame, EnvironmentPrimitive, + EnvironmentSystem, PrecipitationKind, ScreenBillboard, +}; +use fparkan_fx::shadow::{ + ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache, + ShadowScene, ShadowSphere, ShadowTriangle, SHADOW_PRIMARY_DIRECTIONAL_FLAG, +}; +use fparkan_fx::sky::{ + sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame, + SkySystem, SunVisibility, SKY_FLARE_ROWS, +}; +use fparkan_msh::{ + node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times, + ModelAsset, }; use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; -use fparkan_render::{LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform}; +use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; +use fparkan_render::{ + LegacyBlendMode, LegacyD3d7Projection, LegacyDepthMode, LegacyIron3dEulerTransform, + LegacyPipelineState, RawCameraTransform, +}; use fparkan_render_vulkan::{ + node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, + node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh_in_legacy_world_space, - project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount, + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer, + 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_xy_frame_with_node_fallback_poses, - project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, - VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, + 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, }; use fparkan_runtime::{ create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionObjectDraft, MissionRequest, }; -use fparkan_terrain::TerrainMaterialLayers; -use fparkan_terrain::TerrainWorld; +use fparkan_terrain::{FullSurfaceMask, SurfaceQuery, TerrainMaterialLayers, TerrainWorld}; use fparkan_vfs::DirectoryVfs; use serde::Deserialize; +use std::collections::{HashMap, HashSet}; use std::num::NonZeroUsize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize as WinitPhysicalSize; -use winit::event::WindowEvent; +use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, WindowEvent}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; -use winit::window::{Window, WindowId}; +use winit::keyboard::{KeyCode, PhysicalKey}; +use winit::window::{CursorGrabMode, Window, WindowId}; + +mod audio; fn main() { let raw_args = std::env::args().skip(1).collect::>(); @@ -56,7 +91,9 @@ fn run(args: &[String]) -> Result { let args = Args::parse(args)?; let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))); let mut engine = create(services).map_err(|err| err.to_string())?; + let mission_assets_started = Instant::now(); let loaded = load_requested_mission(&mut engine, &args)?; + let mission_assets_duration = mission_assets_started.elapsed(); let mission_assets = loaded_mission_assets(&engine) .ok_or_else(|| "mission assets are unavailable after loading".to_string())?; @@ -73,6 +110,7 @@ fn run(args: &[String]) -> Result { .as_deref() .map(load_legacy_camera_capture) .transpose()?; + let terrain_materials_duration_started = Instant::now(); let preview = static_preview_mesh_and_materials( mission_assets, terrain, @@ -83,13 +121,38 @@ fn run(args: &[String]) -> Result { &args.root, &loaded.land_msh_path, )?; + let terrain_materials_duration = terrain_materials_duration_started.elapsed(); + // Atmosphere files are mission-local assets. Resolving them from the + // entire install tree would silently attach the first unrelated mission's + // sky to the selected map (there are many `sky.ske` files in the campaign + // tree). + let sky_started = Instant::now(); + let environment = load_environment(&args.root, &args.mission, args.atmosphere_seconds)?; + let sky_duration = sky_started.elapsed(); + let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission) + { + Ok(audio) => Some(audio), + Err(audio::AudioError::DeviceUnavailable { message }) => { + eprintln!("audio output unavailable; continuing without sound: {message}"); + None + } + Err(error) => return Err(format!("load mission audio: {error}")), + }; run_static_vulkan_mode( preview, + environment, + audio, args.frames, &args.mission, loaded.object_count, args.readback_out.as_deref(), args.validation, + StartupTimings { + mission_assets: mission_assets_duration, + terrain_materials: terrain_materials_duration, + sky: sky_duration, + gpu_initialization: Duration::ZERO, + }, ) } @@ -108,25 +171,3349 @@ fn load_requested_mission( .map_err(|err| err.to_string()) } -/// Static geometry and descriptors belonging to the selected preview roots. +/// Camera state used by the interactive world-space preview. +#[derive(Clone, Copy, Debug, PartialEq)] +struct FreeFlightCamera { + position: [f32; 3], + yaw: f32, + pitch: f32, + vertical_fov: f32, + near_plane: f32, + far_plane: f32, + move_speed: f32, +} + +impl FreeFlightCamera { + fn from_mesh(mesh: &VulkanStaticMesh) -> Result { + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for vertex in &mesh.vertices { + for axis in 0..3 { + let value = vertex.position[axis]; + if !value.is_finite() { + return Err("world preview contains a non-finite vertex".to_string()); + } + min[axis] = min[axis].min(value); + max[axis] = max[axis].max(value); + } + } + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("world preview has no finite bounds".to_string()); + } + Self::from_bounds(min, max) + } + + fn from_bounds(min: [f32; 3], max: [f32; 3]) -> Result { + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("free-flight bounds must be finite".to_string()); + } + let center = [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + (min[2] + max[2]) * 0.5, + ]; + let radius = (0..3) + .map(|axis| (max[axis] - min[axis]).abs()) + .fold(1.0_f32, f32::max); + if !radius.is_finite() || radius <= f32::EPSILON { + return Err("free-flight bounds are degenerate".to_string()); + } + let position = [ + center[0], + center[1] - radius * 2.5, + center[2] + radius * 1.25, + ]; + let direction = sub3(center, position); + let distance = length3(direction) + .ok_or_else(|| "free-flight initial camera has no direction to the map".to_string())?; + let yaw = direction[1].atan2(direction[0]); + let pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin(); + Ok(Self { + position, + yaw, + pitch, + vertical_fov: std::f32::consts::FRAC_PI_3, + near_plane: (radius * 0.01).clamp(0.05, 0.1), + far_plane: (radius * 32.0).max(100.0), + // Keep the default traversal speed comfortable on a full mission + // map; Shift/Ctrl still provide the coarse speed controls. + move_speed: (radius * 0.03).clamp(4.0, 40.0), + }) + } + + fn from_mesh_and_terrain( + mesh: &VulkanStaticMesh, + terrain: &TerrainWorld, + ) -> Result { + let mut camera = Self::from_mesh(mesh)?; + let positions = terrain + .source_positions() + .ok_or_else(|| "free-flight terrain has no source positions".to_string())?; + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for position in positions { + if !position.iter().all(|value| value.is_finite()) { + return Err("free-flight terrain contains a non-finite position".to_string()); + } + for axis in 0..3 { + min[axis] = min[axis].min(position[axis]); + max[axis] = max[axis].max(position[axis]); + } + } + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("free-flight terrain has no finite bounds".to_string()); + } + let extent = (max[0] - min[0]).max(max[1] - min[1]); + if !extent.is_finite() || extent <= f32::EPSILON { + return Err("free-flight terrain has degenerate XY bounds".to_string()); + } + let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5]; + let offset = extent * 0.18; + let requested_spawn = [ + (center[0] - offset).clamp(min[0], max[0]), + (center[1] - offset).clamp(min[1], max[1]), + ]; + let sample_offsets = [ + [0.0, 0.0], + [0.18, 0.18], + [-0.18, 0.18], + [0.18, -0.18], + [-0.18, -0.18], + [0.0, 0.18], + [0.18, 0.0], + [-0.18, 0.0], + [0.0, -0.18], + ]; + let height_world = |xy: [f32; 2]| { + terrain + .height_at(xy) + .ok() + .flatten() + .filter(|height| height.is_finite()) + .map(|height| height / 32.0) + }; + let (spawn_xy, spawn_ground) = sample_offsets + .into_iter() + .map(|sample| { + [ + (requested_spawn[0] + sample[0] * extent).clamp(min[0], max[0]), + (requested_spawn[1] + sample[1] * extent).clamp(min[1], max[1]), + ] + }) + .find_map(|xy| height_world(xy).map(|height| (xy, height))) + .ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?; + let target_ground = height_world(center).unwrap_or(spawn_ground); + let clearance = (extent * 0.005).clamp(2.0, 8.0); + camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance]; + let target = [center[0], center[1], target_ground + 1.5]; + let direction = sub3(target, camera.position); + let distance = length3(direction) + .ok_or_else(|| "free-flight terrain spawn has no view direction".to_string())?; + camera.yaw = direction[1].atan2(direction[0]); + camera.pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin(); + Ok(camera) + } + + fn forward(self) -> [f32; 3] { + let (sin_yaw, cos_yaw) = self.yaw.sin_cos(); + let (sin_pitch, cos_pitch) = self.pitch.sin_cos(); + [cos_pitch * cos_yaw, cos_pitch * sin_yaw, sin_pitch] + } + + fn right(self) -> [f32; 3] { + normalize3(cross3(self.forward(), [0.0, 0.0, 1.0])).unwrap_or([1.0, 0.0, 0.0]) + } + + fn up(self) -> [f32; 3] { + normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0]) + } + + fn look_delta(&mut self, dx: f64, dy: f64) { + const SENSITIVITY: f32 = 0.0025; + let dx = dx as f32; + let dy = dy as f32; + if dx.is_finite() && dy.is_finite() { + // Moving the mouse right turns the view to the right. + self.yaw -= dx * SENSITIVITY; + self.pitch = (self.pitch - dy * SENSITIVITY).clamp(-1.5, 1.5); + } + } + + fn advance(&mut self, keys: &HashSet, seconds: f32) { + if !seconds.is_finite() || seconds <= 0.0 { + return; + } + let mut movement = [0.0; 3]; + let forward = self.forward(); + let right = self.right(); + if keys.contains(&KeyCode::KeyW) { + add3_in_place(&mut movement, forward); + } + if keys.contains(&KeyCode::KeyS) { + add3_in_place(&mut movement, scale3(forward, -1.0)); + } + if keys.contains(&KeyCode::KeyD) { + add3_in_place(&mut movement, right); + } + if keys.contains(&KeyCode::KeyA) { + add3_in_place(&mut movement, scale3(right, -1.0)); + } + if keys.contains(&KeyCode::KeyE) { + add3_in_place(&mut movement, [0.0, 0.0, 1.0]); + } + if keys.contains(&KeyCode::KeyQ) { + add3_in_place(&mut movement, [0.0, 0.0, -1.0]); + } + let Some(direction) = normalize3(movement) else { + return; + }; + let mut speed = self.move_speed; + if keys.contains(&KeyCode::ShiftLeft) || keys.contains(&KeyCode::ShiftRight) { + speed *= 4.0; + } + if keys.contains(&KeyCode::ControlLeft) || keys.contains(&KeyCode::ControlRight) { + speed *= 0.25; + } + self.position = add3(self.position, scale3(direction, speed * seconds)); + } + + fn vulkan_camera(self, aspect: f32) -> VulkanStaticCamera { + let aspect = if aspect.is_finite() && aspect > f32::EPSILON { + aspect + } else { + 16.0 / 9.0 + }; + let forward = self.forward(); + let right = self.right(); + let up = self.up(); + // VulkanStaticCamera carries row-major D3D-style data. GLSL interprets + // those bytes as a column-major mat4, so shader multiplication is the + // transpose of this row-vector transform: world * V * P. The camera + // basis is right-handed with +Z-up world coordinates and +Z depth. + let view = [ + right[0], + up[0], + forward[0], + 0.0, + right[1], + up[1], + forward[1], + 0.0, + right[2], + up[2], + forward[2], + 0.0, + -dot3(right, self.position), + -dot3(up, self.position), + -dot3(forward, self.position), + 1.0, + ]; + let half_fov = (self.vertical_fov * 0.5).clamp(0.05, 1.5); + let focal = half_fov.tan().recip(); + let near_plane = self.near_plane.max(0.001); + let far_plane = self.far_plane.max(near_plane + 0.001); + let projection = [ + focal / aspect, + 0.0, + 0.0, + 0.0, + 0.0, + -focal, + 0.0, + 0.0, + 0.0, + 0.0, + far_plane / (far_plane - near_plane), + 1.0, + 0.0, + 0.0, + -(near_plane * far_plane) / (far_plane - near_plane), + 0.0, + ]; + VulkanStaticCamera { + clip_from_world: multiply_row_major(view, projection), + } + } +} + +const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4]; +const ENVIRONMENT_FLARE_SLOTS: usize = 12; +const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000; + +#[derive(Clone, Copy, Debug)] +struct EnvironmentGpuRange { + range_index: usize, + vertex_start: usize, + vertex_capacity: usize, + material_index: usize, +} + +#[derive(Clone, Debug)] +struct EnvironmentMaterialAsset { + texture: VulkanStaticTexture, + phase: PreparedMaterialPhase, + document: Mat0Document, + phase_textures: Vec, + phases: Vec, +} + +/// One source MAT0 document retained for frame-time sampling. The renderer +/// receives the selected coefficients through its dynamic material state; +/// keeping this binding beside the prepared scene avoids rebuilding assets or +/// reopening archives on every redraw. +#[derive(Clone, Debug)] +struct MaterialPhaseBinding { + document: Mat0Document, + animation_block_index: usize, + wear_row_start_ms: u32, + random_state: u32, + frozen_phase_index: Option, + phase_uv_transforms: Vec<[f32; 4]>, +} + +/// Texture phases for the material stages that do not select the draw +/// variant. Terrain MAT0 rows are independent: Land1 base, Land2 detail, +/// Land1 overlay, and Land2 overlay-detail can each have their own animation +/// timeline. Keeping these handles beside the binding lets the renderer +/// update the three descriptor images in place without materialising every +/// Cartesian product of phase indices. +#[derive(Clone, Debug)] +struct MaterialStageTextures { + detail: Option>, + overlay: Option>, + overlay_detail: Option>, + applied_material_phases: HashMap, +} + +impl MaterialPhaseBinding { + fn sample( + &mut self, + material_index: usize, + clock_ms: u32, + ) -> Result { + if let Some(phase_index) = self.frozen_phase_index { + let phase_index = phase_index.min(self.document.phases.len().saturating_sub(1)); + let phase = self.document.phases.get(phase_index).ok_or_else(|| { + format!( + "sample MAT0 material {material_index} at clock {clock_ms}ms: no material phases" + ) + })?; + return Ok(MaterialPhaseSample { + phase_index, + coefficients: phase.coefficients(), + texture_raw: phase.texture_raw, + }); + } + + // GetPhase's random mode asks the CRT generator for one fresh value + // per query. Animated loop/ping-pong/clamp modes do not consume the + // RNG and receive the sampler's ignored value. + let block = self + .document + .animation_blocks + .get(self.animation_block_index) + .or_else(|| self.document.animation_blocks.first()); + let random_value = if block.is_some_and(|block| block.header_raw & 7 == 3) { + self.random_state = self + .random_state + .wrapping_mul(214_013) + .wrapping_add(2_531_011); + (self.random_state >> 16) & 0x7fff + } else { + 0 + }; + sample_material_phase( + &self.document, + self.animation_block_index, + clock_ms, + self.wear_row_start_ms, + random_value, + ) + .map_err(|error| { + format!( + "sample MAT0 material {} at clock {clock_ms}ms: {error}", + material_index + ) + }) + } + + fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] { + self.phase_uv_transforms + .get(phase_index) + .copied() + .or_else(|| self.phase_uv_transforms.first().copied()) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + } + + fn checked_page_uv_transform(&self, phase_index: usize) -> Result<[f32; 4], String> { + self.phase_uv_transforms + .get(phase_index) + .copied() + .ok_or_else(|| { + format!( + "MAT0 Page transform is missing phase {phase_index} of {}", + self.phase_uv_transforms.len() + ) + }) + } +} + +#[derive(Clone, Debug)] +struct MaterialVariantSet { + phase_material_indices: Vec, + range_indices: Vec, +} + +#[derive(Clone, Debug)] +struct MaterialAnimationBinding { + initial_phase_index: usize, + variants: Vec, + base: MaterialPhaseBinding, + detail: Option, + overlay: Option, + overlay_detail: Option, + stage_textures: Option, +} + +#[derive(Clone, Copy, Debug)] +struct ActiveMaterialState { + material_index: usize, + diffuse_alpha: f32, +} + +fn update_material_animations( + bindings: &mut [MaterialAnimationBinding], + materials: &[VulkanStaticMaterial], + renderer: &mut VulkanSmokeRenderer, + clock_ms: u32, +) -> Result, String> { + // Keep the resolved selector beside the draw range for this frame. The + // renderer's material arrays are keyed by selector, so any later + // environment pass that writes alpha or lighting must target the phase + // variant selected above rather than the material that happened to be + // active when the range was allocated. + let mut active_materials = HashMap::new(); + for binding in bindings { + let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?; + let detail_sample = binding + .detail + .as_mut() + .map(|detail| detail.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let overlay_sample = binding + .overlay + .as_mut() + .map(|overlay| overlay.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let overlay_detail_sample = binding + .overlay_detail + .as_mut() + .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let mut updated_materials = HashSet::new(); + for variant in &binding.variants { + let material_index = selected_material_variant_index(variant, base_sample.phase_index) + .ok_or_else(|| "MAT0 material animation has no texture variant".to_string())?; + for &range_index in &variant.range_indices { + active_materials.insert( + range_index, + ActiveMaterialState { + material_index, + diffuse_alpha: base_sample.coefficients.opacity, + }, + ); + renderer + .set_draw_range_material(range_index, material_index) + .map_err(|error| format!("update MAT0 material draw selector: {error}"))?; + } + if !updated_materials.insert(material_index) { + continue; + } + let material = materials.get(material_index).ok_or_else(|| { + format!("MAT0 material animation selector {material_index} is out of bounds") + })?; + let base_coefficients = base_sample.coefficients; + let overlay_coefficients = overlay_sample.map(|sample| sample.coefficients); + 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. + ([0.0; 3], base_coefficients.directional_rgb, None, None) + } else { + ( + base_coefficients.directional_rgb, + base_coefficients.additive_rgb, + overlay_coefficients.map(|coefficients| coefficients.directional_rgb), + overlay_coefficients.map(|coefficients| coefficients.additive_rgb), + ) + }; + renderer + .set_material_alphas( + material_index, + base_coefficients.opacity.clamp(0.0, 1.0), + overlay_coefficients + .map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)), + ) + .map_err(|error| format!("update MAT0 material alpha: {error}"))?; + renderer + .set_material_lighting_with_overlay( + material_index, + base_directional, + base_additive, + overlay_directional, + overlay_additive, + ) + .map_err(|error| format!("update MAT0 material lighting: {error}"))?; + renderer + .set_material_specular( + material_index, + base_coefficients.specular_rgb, + base_coefficients.power, + overlay_coefficients.map(|coefficients| coefficients.specular_rgb), + overlay_coefficients.map(|coefficients| coefficients.power), + ) + .map_err(|error| format!("update MAT0 material specular: {error}"))?; + + // Detail and overlay textures have independent MAT0 phase + // selectors. Their descriptor images are updated only when a + // selected base material sees a new stage tuple; this keeps the + // steady-state frame path from idling Vulkan for every draw. + if !material.lightmap_mode { + if let Some(stage_textures) = binding.stage_textures.as_mut() { + 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) + { + let detail_texture = stage_textures + .detail + .as_ref() + .and_then(|textures| textures.get(stage_phases[0])) + .cloned(); + let overlay_texture = stage_textures + .overlay + .as_ref() + .and_then(|textures| textures.get(stage_phases[1])) + .cloned(); + let overlay_detail_texture = stage_textures + .overlay_detail + .as_ref() + .and_then(|textures| textures.get(stage_phases[2])) + .cloned(); + if let Some(texture) = detail_texture.as_ref() { + renderer + .update_material_texture(material_index, 1, texture) + .map_err(|error| format!("update MAT0 detail texture: {error}"))?; + } + if let Some(texture) = overlay_texture.as_ref() { + renderer + .update_material_texture(material_index, 2, texture) + .map_err(|error| format!("update MAT0 overlay texture: {error}"))?; + } + if let Some(texture) = overlay_detail_texture.as_ref() { + renderer + .update_material_texture(material_index, 3, texture) + .map_err(|error| { + format!("update MAT0 overlay detail texture: {error}") + })?; + } + stage_textures + .applied_material_phases + .insert(material_index, stage_phases); + } + } + } + let overlay_uv = overlay_sample + .map(|sample| { + binding + .overlay + .as_ref() + .map_or([0.0, 0.0, 1.0, 1.0], |overlay| { + overlay.page_uv_transform(sample.phase_index) + }) + }) + .or(material.overlay_uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]); + let detail_uv = if material.lightmap_mode { + material.detail_uv_transform + } else { + detail_sample + .map(|sample| { + binding + .detail + .as_ref() + .map_or(material.detail_uv_transform, |detail| { + detail.page_uv_transform(sample.phase_index) + }) + }) + .or(Some(material.detail_uv_transform)) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }; + let overlay_detail_uv = if material.lightmap_mode { + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + } else { + overlay_detail_sample + .map(|sample| { + binding.overlay_detail.as_ref().map_or( + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]), + |overlay_detail| overlay_detail.page_uv_transform(sample.phase_index), + ) + }) + .or(material.overlay_detail_uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }; + let base_uv = binding + .base + .checked_page_uv_transform(base_sample.phase_index)?; + renderer + .set_material_uv_transforms( + material_index, + [base_uv, detail_uv, overlay_uv, overlay_detail_uv], + ) + .map_err(|error| format!("update MAT0 material Page transform: {error}"))?; + } + } + Ok(active_materials) +} + +fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -> bool { + diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 +} + +fn register_material_range( + bindings: &mut [MaterialAnimationBinding], + material_index: usize, + range_index: usize, +) { + for binding in bindings { + if let Some(variant) = binding.variants.iter_mut().find(|variant| { + variant + .phase_material_indices + .get(binding.initial_phase_index) + .copied() + == Some(material_index) + }) { + variant.range_indices.push(range_index); + return; + } + } +} + +fn find_material_animation_variant( + bindings: &[MaterialAnimationBinding], + material_index: usize, +) -> Option<(usize, Vec)> { + bindings + .iter() + .enumerate() + .find_map(|(binding_index, binding)| { + binding.variants.iter().find_map(|variant| { + (variant + .phase_material_indices + .get(binding.initial_phase_index) + .copied() + == Some(material_index)) + .then(|| (binding_index, variant.phase_material_indices.clone())) + }) + }) +} + +fn selected_material_variant_index( + variant: &MaterialVariantSet, + phase_index: usize, +) -> Option { + variant.phase_material_indices.get(phase_index).copied() +} + +fn remap_material_animation_ranges( + bindings: &mut [MaterialAnimationBinding], + remap: &[usize], +) -> Result<(), String> { + for binding in bindings { + for variant in &mut binding.variants { + for range_index in &mut variant.range_indices { + *range_index = *remap + .get(*range_index) + .ok_or_else(|| "MAT0 draw range remap is out of bounds".to_string())?; + } + } + } + Ok(()) +} + +fn phase_texture_name(phase: &MaterialPhase) -> Option { + let length = phase + .texture_raw + .iter() + .position(|byte| *byte == 0) + .unwrap_or(phase.texture_raw.len()); + (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec())) +} + +fn phase_uv_transforms_from_assets( + document: &Mat0Document, + assets: &MissionAssets, +) -> Vec<[f32; 4]> { + document + .phases + .iter() + .map(|phase| { + phase_texture_name(phase) + .and_then(|name| { + assets + .textures + .iter() + .find(|texture| { + texture.usage == PreparedTextureUsage::Diffuse + && texture.source.name == name + }) + .and_then(|texture| { + page_uv_transform( + texture.texm.width(), + texture.texm.height(), + &texture.texm.page_rects(), + i8::from_ne_bytes([phase.parameters[17]]), + ) + .ok() + }) + }) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }) + .collect() +} + +fn phase_uv_transforms_from_standalone( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + document: &Mat0Document, +) -> Result, String> { + document + .phases + .iter() + .enumerate() + .map(|(phase_index, phase)| { + if phase_texture_name(phase).is_none() { + return Ok([0.0, 0.0, 1.0, 1.0]); + } + loader + .load( + wear_path, + material_index, + u16::try_from(phase_index).unwrap_or(u16::MAX), + ) + .map(|(_, prepared)| prepared.page_uv_transform) + }) + .collect() +} + +#[derive(Clone, Copy, Debug)] +struct EnvironmentSkyLayer { + kind: SkyPassKind, + stage: Option, + secondary_stage: Option, + material_index: usize, + diffuse_alpha: f32, + static_directional_rgb: [f32; 3], + static_additive_rgb: [f32; 3], + translation_z: f32, + range: EnvironmentGpuRange, +} + +#[derive(Clone, Copy, Debug)] +struct EnvironmentSpriteLayer { + row: usize, + material_index: usize, + diffuse_alpha: f32, + flare_index: Option, + range: EnvironmentGpuRange, +} + +/// Fixed-capacity GPU allocations for the CPU-owned sky and environment +/// output. The Vulkan renderer only receives one shared vertex update and +/// smaller draw counts each frame; descriptor images and index allocations +/// stay stable while the camera and weather move. +#[derive(Clone, Debug)] +struct EnvironmentGpuScene { + material_animations: Vec, + sky_layers: Vec, + sprites: Vec, + rain: EnvironmentGpuRange, + snow: EnvironmentGpuRange, + lightning: EnvironmentGpuRange, + lightning_material_index: usize, +} + +fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> LegacyPipelineState { + LegacyPipelineState { + blend, + depth, + ..LegacyPipelineState::default() + } +} + +fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex { + environment_vertex_with_alpha(position, color, 1.0, uv) +} + +fn environment_vertex_with_alpha( + position: [f32; 3], + color: [f32; 3], + alpha: f32, + uv: [f32; 2], +) -> VulkanStaticVertex { + environment_vertex_with_alpha_normal(position, color, alpha, uv, [0.0, 0.0, 1.0]) +} + +fn environment_vertex_with_alpha_normal( + position: [f32; 3], + color: [f32; 3], + alpha: f32, + uv: [f32; 2], + normal: [f32; 3], +) -> VulkanStaticVertex { + VulkanStaticVertex { + position, + color, + normal, + uv, + detail_uv: [0.0, 0.0], + overlay_alpha: alpha.clamp(0.0, 1.0), + } +} + +fn sky_normal(raw: [i8; 3]) -> [f32; 3] { + // Ngi32's cloud-light decode uses the signed-byte scale 1/128 and keeps + // the authored vector length. Renormalizing (or using 1/127) changes the + // edge values and shifts the native cloud shading. + [ + f32::from(raw[0]) / 128.0, + f32::from(raw[1]) / 128.0, + f32::from(raw[2]) / 128.0, + ] +} + +fn solid_environment_texture(rgba: [u8; 4]) -> VulkanStaticTexture { + let mip = VulkanStaticTextureMip { + width: 1, + height: 1, + rgba8: rgba.to_vec(), + }; + VulkanStaticTexture { + width: 1, + height: 1, + rgba8: mip.rgba8.clone(), + mip_levels: vec![mip], + } +} + +fn vulkan_texture_from_rgba_mips( + mips: Vec, + label: &str, +) -> Result { + let first = mips + .first() + .ok_or_else(|| format!("{label} has no mip zero"))?; + Ok(VulkanStaticTexture { + width: first.width, + height: first.height, + rgba8: first.rgba8.clone(), + mip_levels: mips + .into_iter() + .map(|mip| VulkanStaticTextureMip { + width: mip.width, + height: mip.height, + rgba8: mip.rgba8, + }) + .collect(), + }) +} + +fn append_environment_material( + materials: &mut Vec, + material_names: &mut HashMap, + material_assets: &HashMap, + material_animations: &mut Vec, + name: &str, + fallback_row: usize, +) -> Result { + if let Some(index) = material_names.get(name).copied() { + return Ok(index); + } + let asset = material_assets.get(name).ok_or_else(|| { + format!("environment material {name:?} is missing from resolved material.lib/MAT0 assets") + })?; + let phases = if asset.phases.is_empty() { + vec![asset.phase] + } else { + asset.phases.clone() + }; + let first_material_index = materials.len(); + let mut phase_material_indices = Vec::with_capacity(phases.len()); + for (phase_index, phase) in phases.iter().enumerate() { + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "environment exceeds the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + texture: asset + .phase_textures + .get(phase_index) + .cloned() + .unwrap_or_else(|| asset.texture.clone()), + 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, + diffuse_alpha: phase.diffuse_alpha.clamp(0.0, 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, + // Native cloud draws carry shade/fog flags. The remaining sky rows + // are unlit overlays; all rows still retain their decoded normals. + unlit: fallback_row != 2, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: matches!(fallback_row, 0..=2), + }); + phase_material_indices.push(index); + } + let document = asset.document.clone(); + material_animations.push(MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: first_material_index as u32, + frozen_phase_index: None, + phase_uv_transforms: phases.iter().map(|phase| phase.page_uv_transform).collect(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + material_names.insert(name.to_owned(), first_material_index); + Ok(first_material_index) +} + +fn append_environment_gradient_material( + materials: &mut Vec, +) -> Result { + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "environment exceeds the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + texture: solid_environment_texture([255, 255, 255, 255]), + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + uv_transform: [0.0, 0.0, 1.0, 1.0], + detail_texture: Some(solid_environment_texture([128, 128, 128, 255])), + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: [0.0; 3], + specular_power: 0, + 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: true, + }); + Ok(index) +} + +fn shadow_atlas_texture() -> VulkanStaticTexture { + let rgba8 = vec![0_u8; 256 * 256 * 4]; + VulkanStaticTexture { + width: 256, + height: 256, + rgba8: rgba8.clone(), + mip_levels: vec![VulkanStaticTextureMip { + width: 256, + height: 256, + rgba8, + }], + } +} + +fn append_shadow_material(materials: &mut Vec) -> Result { + let material_index = u16::try_from(materials.len()).map_err(|_| { + "shadow material selector exceeds the available 16-bit material selector space".to_string() + })?; + let index = materials.len(); + materials.push(VulkanStaticMaterial { + material_index, + texture: shadow_atlas_texture(), + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + uv_transform: [0.0, 0.0, 1.0, 1.0], + detail_texture: Some(solid_environment_texture([255, 255, 255, 255])), + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: [0.0; 3], + specular_power: 0, + 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, + // Combiner mode 1 keeps the atlas alpha and the projected receiver's + // per-vertex alpha while bypassing world lighting/fog. + unlit: true, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + Ok(index) +} + +fn shadow_placeholder_vertices() -> Vec { + [ + environment_vertex_with_alpha([0.0, 0.0, 0.0], [1.0; 3], 0.0, [0.0, 0.0]), + environment_vertex_with_alpha([1.0, 0.0, 0.0], [1.0; 3], 0.0, [1.0, 0.0]), + environment_vertex_with_alpha([0.0, 1.0, 0.0], [1.0; 3], 0.0, [0.0, 1.0]), + environment_vertex_with_alpha([1.0, 1.0, 0.0], [1.0; 3], 0.0, [1.0, 1.0]), + ] + .to_vec() +} + +fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle { + let normal = normalize3(cross3( + sub3(positions[1], positions[0]), + sub3(positions[2], positions[0]), + )) + .unwrap_or([0.0, 0.0, 1.0]); + ShadowTriangle { + positions, + normal, + mask, + } +} + +fn terrain_shadow_receivers( + terrain: &fparkan_terrain_format::LandMeshDocument, +) -> Result, String> { + terrain + .faces + .iter() + .map(|face| { + let positions = face.vertices.map(|index| { + let source = terrain + .positions + .get(usize::from(index)) + .copied() + .unwrap_or([0.0; 3]); + [source[0], source[1], source[2] / 32.0] + }); + 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 static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec { + mesh.indices + .chunks_exact(3) + .filter_map(|triangle| { + let positions = [ + mesh.vertices + .get(usize::try_from(triangle[0]).ok()?)? + .position, + mesh.vertices + .get(usize::try_from(triangle[1]).ok()?)? + .position, + mesh.vertices + .get(usize::try_from(triangle[2]).ok()?)? + .position, + ]; + positions + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(shadow_triangle(positions, 0)) + }) + .collect() +} + +#[derive(Clone, Copy, Debug)] +struct ShadowComponentSphere { + center: [f32; 3], + radius: f32, +} + +fn mounted_shadow_sphere( + center: [f32; 3], + radius: f32, + mount_pose: Option, + node1_pose: Option, +) -> Option { + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= 0.0 { + return None; + } + let center = mount_pose.map_or(center, |parent_pose| { + // The native mounted-unit sphere query keeps node 1's accumulated + // rotation but replaces its translation with the accumulated parent + // translation. This is deliberately different from the render mount + // composition, which also carries node 1's translation. + let rotation = node1_pose.map_or(parent_pose.rotation, |node_pose| { + multiply_unit_quaternions(parent_pose.rotation, node_pose.rotation) + }); + add3( + parent_pose.translation, + rotate_unit_vector(center, rotation), + ) + }); + center + .iter() + .all(|value| value.is_finite()) + .then_some(ShadowComponentSphere { center, radius }) +} + +fn aggregate_shadow_caster( + id: u32, + native_kind: u32, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + triangles: Vec, + component_spheres: &[ShadowComponentSphere], +) -> Option { + if triangles.is_empty() { + return None; + } + let weight = component_spheres + .iter() + .map(|sphere| sphere.radius) + .sum::(); + if !weight.is_finite() || weight <= f32::EPSILON { + return None; + } + // Native 9510 aggregates visible root/EXTO spheres by radius-weighted + // center, then encloses every component sphere around that center. + let aggregate_center = component_spheres.iter().fold([0.0; 3], |sum, sphere| { + add3(sum, scale3(sphere.center, sphere.radius)) + }); + let aggregate_center = scale3(aggregate_center, weight.recip()); + let radius = component_spheres + .iter() + .map(|sphere| length3(sub3(sphere.center, aggregate_center)).unwrap_or(0.0) + sphere.radius) + .fold(0.0, f32::max); + let max_scale = scale.iter().copied().fold(f32::NEG_INFINITY, f32::max); + if !aggregate_center.iter().all(|value| value.is_finite()) + || !radius.is_finite() + || radius <= f32::EPSILON + || !max_scale.is_finite() + || max_scale <= 0.0 + { + return None; + } + // The native scale flag applies to the aggregate exactly once. The + // mission Euler transform then rotates/translates that scaled sphere. + let scaled_center = [ + aggregate_center[0] * scale[0], + aggregate_center[1] * scale[1], + aggregate_center[2] * scale[2], + ]; + let center = transform.try_transform_scaled_point(scaled_center, [1.0; 3])?; + let radius = radius * max_scale; + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() { + return None; + } + let world_axes = transform + .try_row_major() + .map(|matrix| { + [ + [matrix[0], matrix[4], matrix[8]], + [matrix[1], matrix[5], matrix[9]], + [matrix[2], matrix[6], matrix[10]], + ] + }) + .unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + Some(ShadowCaster { + id, + world_axes, + sphere: ShadowSphere { center, radius }, + native_kind, + triangles, + }) +} + +fn shadow_lights( + sky_frame: Option<&SkyFrame<'_>>, + environment_frame: Option<&EnvironmentFrame>, + renderer: &VulkanSmokeRenderer, +) -> Vec { + let mut lights = Vec::new(); + if let Some(sky_frame) = sky_frame { + lights.extend(sky_frame.directional_lights.into_iter().enumerate().map( + |(index, light)| { + ShadowLight::Directional { + direction: light.direction, + rgb: light.color, + active: light.active, + // CSun's constructor marks both the sun (slot 0) and + // moon (slot 2) as primary. ShadowReference selects the + // first one for kind 3 and accumulates both active + // primary directions for ordinary objects (kind 4). + flags: (index == 0 || index == 2) + .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) + .unwrap_or(0), + } + }, + )); + } else { + let uniforms = renderer.frame_uniforms(); + lights.extend( + uniforms + .directional_lights + .into_iter() + .enumerate() + .map(|(index, light)| ShadowLight::Directional { + direction: light.direction, + rgb: light.rgb, + active: light.active, + flags: (index == 0 || index == 2) + .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) + .unwrap_or(0), + }), + ); + } + if let Some(environment_frame) = environment_frame { + lights.extend( + environment_frame + .point_lights + .iter() + .map(|light| ShadowLight::Point { + position: light.position, + rgb: [light.color[0], light.color[1], light.color[2]], + range: light.range, + coefficients: light.attenuation, + active: light.active, + flags: 0, + }), + ); + } else { + let point = renderer.frame_uniforms().point_light; + lights.push(ShadowLight::Point { + position: point.position, + rgb: point.rgb, + range: point.range, + coefficients: point.coefficients, + active: point.active, + flags: 0, + }); + } + lights +} + +/// Native `Terrain30737` expands each caster sphere by 100 world units before +/// testing it against the six planes in the current world frustum. Filtering +/// here matters because `CShade` only accepts its first twenty candidates; +/// off-screen objects must not consume that budget. +const SHADOW_FRUSTUM_RADIUS_MARGIN: f32 = 100.0; + +fn native_shadow_caster_visible(caster: &ShadowCaster, clip_from_world: [f32; 16]) -> bool { + let radius = caster.sphere.radius + SHADOW_FRUSTUM_RADIUS_MARGIN; + if !radius.is_finite() || radius < 0.0 || !caster.sphere.center.iter().all(|v| v.is_finite()) { + return false; + } + + // The camera matrix is row-major D3D-style storage. A world point is + // transformed by p * M, so each clip coordinate uses one matrix column. + // The six Vulkan clip half-spaces are x+w, -x+w, y+w, -y+w, z, and w-z. + let planes = [ + [ + clip_from_world[0] + clip_from_world[3], + clip_from_world[4] + clip_from_world[7], + clip_from_world[8] + clip_from_world[11], + clip_from_world[12] + clip_from_world[15], + ], + [ + -clip_from_world[0] + clip_from_world[3], + -clip_from_world[4] + clip_from_world[7], + -clip_from_world[8] + clip_from_world[11], + -clip_from_world[12] + clip_from_world[15], + ], + [ + clip_from_world[1] + clip_from_world[3], + clip_from_world[5] + clip_from_world[7], + clip_from_world[9] + clip_from_world[11], + clip_from_world[13] + clip_from_world[15], + ], + [ + -clip_from_world[1] + clip_from_world[3], + -clip_from_world[5] + clip_from_world[7], + -clip_from_world[9] + clip_from_world[11], + -clip_from_world[13] + clip_from_world[15], + ], + [ + clip_from_world[2], + clip_from_world[6], + clip_from_world[10], + clip_from_world[14], + ], + [ + clip_from_world[3] - clip_from_world[2], + clip_from_world[7] - clip_from_world[6], + clip_from_world[11] - clip_from_world[10], + clip_from_world[15] - clip_from_world[14], + ], + ]; + planes.into_iter().all(|plane| { + let normal_length = + (plane[0].mul_add(plane[0], plane[1].mul_add(plane[1], plane[2] * plane[2]))).sqrt(); + if !normal_length.is_finite() || normal_length <= f32::EPSILON { + return false; + } + let distance = plane[0].mul_add( + caster.sphere.center[0], + plane[1].mul_add( + caster.sphere.center[1], + plane[2].mul_add(caster.sphere.center[2], plane[3]), + ), + ) / normal_length; + distance.is_finite() && distance > -radius + }) +} + +fn native_visible_shadow_casters( + casters: &[ShadowCaster], + clip_from_world: [f32; 16], +) -> Vec { + casters + .iter() + .filter(|caster| native_shadow_caster_visible(caster, clip_from_world)) + .cloned() + .collect() +} + +fn append_shadow_frame_geometry( + mesh: &mut VulkanStaticMesh, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + frame: &ShadowFrame, +) -> Result { + if shadow_base_vertex > mesh.vertices.len() || shadow_base_index > mesh.indices.len() { + return Err("shadow dynamic base is outside the shared mesh".to_string()); + } + mesh.vertices.truncate(shadow_base_vertex); + mesh.indices.truncate(shadow_base_index); + let vertex_base = u32::try_from(mesh.vertices.len()) + .map_err(|_| "shadow vertex base exceeds u32".to_string())?; + let first_index = u32::try_from(mesh.indices.len()) + .map_err(|_| "shadow index base exceeds u32".to_string())?; + if frame.indices.iter().any(|&index| { + usize::try_from(index) + .ok() + .is_none_or(|index| index >= frame.vertices.len()) + }) { + return Err("shadow frame index exceeds its vertex stream".to_string()); + } + mesh.vertices.extend(frame.vertices.iter().map(|vertex| { + environment_vertex_with_alpha_normal( + vertex.position, + [1.0; 3], + vertex.alpha.clamp(0.0, 1.0), + vertex.uv, + vertex.normal, + ) + })); + mesh.indices.extend( + frame + .indices + .iter() + .map(|&index| { + vertex_base + .checked_add(index) + .ok_or_else(|| "shadow index exceeds u32".to_string()) + }) + .collect::, _>>()?, + ); + let index_count = u32::try_from(frame.indices.len()) + .map_err(|_| "shadow index count exceeds u32".to_string())?; + if let Some(range) = mesh.draw_ranges.get_mut(shadow_range.range_index) { + range.first_index = first_index; + range.index_count = index_count; + } + Ok(VulkanDynamicDrawRange { + range_index: shadow_range.range_index, + first_index, + index_count, + }) +} + +fn shadow_atlas_texture_from_frame(frame: &ShadowFrame) -> VulkanStaticTexture { + VulkanStaticTexture { + width: 256, + height: 256, + rgba8: frame.atlas_rgba8.clone(), + mip_levels: vec![VulkanStaticTextureMip { + width: 256, + height: 256, + rgba8: frame.atlas_rgba8.clone(), + }], + } +} + +fn append_environment_range( + mesh: &mut VulkanStaticMesh, + material_index: usize, + vertices: Vec, + local_indices: Vec, + pipeline_state: LegacyPipelineState, +) -> Result { + if vertices.is_empty() || local_indices.is_empty() || !local_indices.len().is_multiple_of(3) { + return Err("environment geometry range is empty or not triangle-aligned".to_string()); + } + let vertex_start = mesh.vertices.len(); + let first_index = mesh.indices.len(); + let material_index = u16::try_from(material_index) + .map_err(|_| "environment material selector exceeds u16".to_string())?; + let vertex_start_u32 = u32::try_from(vertex_start) + .map_err(|_| "environment vertex allocation exceeds u32".to_string())?; + for index in local_indices { + mesh.indices.push( + vertex_start_u32 + .checked_add(index) + .ok_or_else(|| "environment index exceeds u32".to_string())?, + ); + } + let index_count = u32::try_from(mesh.indices.len() - first_index) + .map_err(|_| "environment index allocation exceeds u32".to_string())?; + let vertex_capacity = vertices.len(); + mesh.vertices.extend(vertices); + let first_index = u32::try_from(first_index) + .map_err(|_| "environment first index exceeds u32".to_string())?; + let range_index = mesh.draw_ranges.len(); + mesh.draw_ranges + .push(fparkan_render_vulkan::VulkanStaticDrawRange { + first_index, + index_count, + material_index, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state, + alpha_test_reference: 0, + }); + Ok(EnvironmentGpuRange { + range_index, + vertex_start, + vertex_capacity, + material_index: usize::from(material_index), + }) +} + +/// Reorders indexed draw segments without breaking the static mesh contract. +/// +/// Draw ranges are required to cover one contiguous index buffer. Changing +/// only their vector order therefore is insufficient when a background pass +/// must run before mission geometry: the corresponding index segments have to +/// move with them and every range receives a new `first_index`. +fn reorder_draw_ranges( + mesh: &mut VulkanStaticMesh, + old_order: &[usize], +) -> Result, String> { + if old_order.len() != mesh.draw_ranges.len() { + return Err("draw-range reorder does not cover the whole mesh".to_string()); + } + let mut seen = vec![false; mesh.draw_ranges.len()]; + let old_ranges = mesh.draw_ranges.clone(); + let old_indices = mesh.indices.clone(); + let mut indices = Vec::with_capacity(old_indices.len()); + let mut ranges = Vec::with_capacity(old_ranges.len()); + let mut remap = vec![usize::MAX; old_ranges.len()]; + for (new_index, &old_index) in old_order.iter().enumerate() { + if old_index >= old_ranges.len() || seen[old_index] { + return Err("draw-range reorder contains a duplicate or invalid range".to_string()); + } + seen[old_index] = true; + let old_range = old_ranges[old_index]; + let first = usize::try_from(old_range.first_index) + .map_err(|_| "draw-range first index exceeds addressable memory".to_string())?; + let count = usize::try_from(old_range.index_count) + .map_err(|_| "draw-range index count exceeds addressable memory".to_string())?; + let end = first + .checked_add(count) + .ok_or_else(|| "draw-range index segment overflows address space".to_string())?; + let segment = old_indices + .get(first..end) + .ok_or_else(|| "draw-range index segment is outside the mesh".to_string())?; + let first_index = u32::try_from(indices.len()) + .map_err(|_| "reordered index buffer exceeds u32".to_string())?; + indices.extend_from_slice(segment); + let mut range = old_range; + range.first_index = first_index; + ranges.push(range); + remap[old_index] = new_index; + } + if seen.iter().any(|seen| !seen) || indices.len() != old_indices.len() { + return Err("draw-range reorder does not cover the whole index buffer".to_string()); + } + mesh.indices = indices; + mesh.draw_ranges = ranges; + Ok(remap) +} + +fn quad_indices(slot_count: usize) -> Result, String> { + let mut indices = Vec::with_capacity( + slot_count + .checked_mul(6) + .ok_or_else(|| "environment quad index count overflow".to_string())?, + ); + for slot in 0..slot_count { + let base = u32::try_from( + slot.checked_mul(4) + .ok_or_else(|| "environment quad vertex count overflow".to_string())?, + ) + .map_err(|_| "environment quad vertex count exceeds u32".to_string())?; + indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 1, base + 3]); + } + Ok(indices) +} + +fn placeholder_quad_vertices( + camera: &FreeFlightCamera, + slot_count: usize, + size: f32, +) -> Vec { + let center = add3( + camera.position, + scale3(camera.forward(), camera.far_plane * 0.5), + ); + let right = scale3(camera.right(), size); + let up = scale3(camera.up(), size); + (0..slot_count) + .flat_map(|_| { + [ + environment_vertex(sub3(sub3(center, right), up), [1.0; 3], [0.0, 1.0]), + environment_vertex(add3(sub3(center, up), right), [1.0; 3], [1.0, 1.0]), + environment_vertex(add3(sub3(center, right), up), [1.0; 3], [0.0, 0.0]), + environment_vertex(add3(add3(center, right), up), [1.0; 3], [1.0, 0.0]), + ] + }) + .collect() +} + +fn screen_gradient_quad_vertices( + camera: &FreeFlightCamera, + viewport: [f32; 2], + color: [f32; 3], +) -> [VulkanStaticVertex; 4] { + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let aspect = width / height; + let fov = camera.vertical_fov.clamp(0.05, 3.0); + // The quad is placed close to the camera and drawn with depth disabled. + // It therefore covers the complete projected viewport while the later + // dome, terrain, and object ranges provide their own depth-tested detail. + let distance = 1.0_f32; + let half_height = distance * (fov * 0.5).tan() * 1.05; + let half_width = half_height * aspect; + let center = add3(camera.position, scale3(camera.forward(), distance)); + let right = scale3(camera.right(), half_width); + let up = scale3(camera.up(), half_height); + [ + environment_vertex(sub3(sub3(center, right), up), color, [0.0, 1.0]), + environment_vertex(add3(sub3(center, up), right), color, [1.0, 1.0]), + environment_vertex(add3(sub3(center, right), up), color, [0.0, 0.0]), + environment_vertex(add3(add3(center, right), up), color, [1.0, 0.0]), + ] +} + +impl EnvironmentGpuScene { + fn fixed_range_indices(&self) -> HashSet { + self.sky_layers + .iter() + .map(|layer| layer.range.range_index) + .chain(self.sprites.iter().map(|sprite| sprite.range.range_index)) + .chain([ + self.rain.range_index, + self.snow.range_index, + self.lightning.range_index, + ]) + .collect() + } + + fn new( + mesh: &mut VulkanStaticMesh, + materials: &mut Vec, + preview_material_animations: &mut Vec, + sun_occlusion_range_indices: &mut Vec, + environment_materials: &SkyMaterials, + material_assets: &HashMap, + sky_mesh: &SkyMesh, + camera: &FreeFlightCamera, + shadow_range_index: &mut usize, + ) -> Result { + let mut names = HashMap::new(); + let mut material_animations = Vec::new(); + let mut material_indices = [None; 9]; + let fallback_names = SkyMaterials::default(); + for row in 0..9 { + let name = environment_materials + .material_name(row) + .or_else(|| fallback_names.material_name(row)) + .unwrap_or("ENVIRONMENT"); + let index = append_environment_material( + materials, + &mut names, + material_assets, + &mut material_animations, + name, + row, + )?; + material_indices[row] = Some(index); + } + let gradient_material_index = append_environment_gradient_material(materials)?; + let nebula_material_index = material_indices[0] + .ok_or_else(|| "nebula material selector is unavailable".to_string())?; + let stars_material_index = material_indices[1] + .ok_or_else(|| "stars material selector is unavailable".to_string())?; + // Native sky mode 4 uses one descriptor set: TEX0 is the nebula and + // TEX1 is the independently animated stars texture whose alpha chooses + // the contribution. Keep the stars MAT0 binding as the nebula binding's + // detail stage; do not form a Cartesian product of the two phase rows. + let (nebula_binding_index, nebula_phase_materials) = + find_material_animation_variant(&material_animations, nebula_material_index) + .ok_or_else(|| { + format!("MAT0 sky material {nebula_material_index} has no phase variants") + })?; + let (stars_binding_index, stars_phase_materials) = + find_material_animation_variant(&material_animations, stars_material_index) + .ok_or_else(|| { + format!("MAT0 sky material {stars_material_index} has no phase variants") + })?; + if stars_phase_materials.is_empty() { + return Err("stars material has no phase variants".to_string()); + } + let stars_descriptors = stars_phase_materials + .iter() + .map(|&material_index| { + let material = materials.get(material_index).ok_or_else(|| { + format!("stars material descriptor {material_index} is unavailable") + })?; + Ok((material.texture.clone(), material.uv_transform)) + }) + .collect::, String>>()?; + let stars_binding = material_animations + .get(stars_binding_index) + .ok_or_else(|| "stars material animation binding is unavailable".to_string())? + .base + .clone(); + let stars_stage_textures = stars_descriptors + .iter() + .map(|(texture, _)| texture.clone()) + .collect(); + let stars_initial_uv = stars_descriptors + .first() + .map(|(_, uv_transform)| *uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]); + for &material_index in &nebula_phase_materials { + let stars_texture = stars_descriptors + .first() + .map(|(texture, _)| texture.clone()) + .ok_or_else(|| "stars material descriptor is unavailable".to_string())?; + let nebula = materials.get_mut(material_index).ok_or_else(|| { + format!("nebula material descriptor {material_index} is unavailable") + })?; + nebula.detail_texture = Some(stars_texture); + nebula.detail_uv_transform = stars_initial_uv; + nebula.sky_nebula_stars = true; + } + let nebula_binding = material_animations + .get_mut(nebula_binding_index) + .ok_or_else(|| "nebula material animation binding is unavailable".to_string())?; + nebula_binding.detail = Some(stars_binding); + nebula_binding.stage_textures = Some(MaterialStageTextures { + detail: Some(stars_stage_textures), + overlay: None, + overlay_detail: None, + // Force TEX1 to the independently sampled stars phase on the first + // frame, including when the initial star descriptor is already set. + applied_material_phases: HashMap::new(), + }); + let lightning_material_index = append_environment_material( + materials, + &mut names, + material_assets, + &mut material_animations, + "env_lightning", + 3, + )?; + + let mut scene = Self { + material_animations, + sky_layers: Vec::with_capacity(4), + sprites: Vec::with_capacity(ENVIRONMENT_SPRITE_ROWS.len() + ENVIRONMENT_FLARE_SLOTS), + rain: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: material_indices[8].unwrap_or(0), + }, + snow: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: material_indices[7].unwrap_or(0), + }, + lightning: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: lightning_material_index, + }, + lightning_material_index, + }; + + let screen_range = append_environment_range( + mesh, + gradient_material_index, + screen_gradient_quad_vertices(camera, [16.0, 9.0], [1.0; 3]).to_vec(), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::Disabled), + )?; + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::ScreenGradient, + stage: None, + secondary_stage: None, + material_index: gradient_material_index, + diffuse_alpha: 1.0, + static_directional_rgb: [1.0; 3], + static_additive_rgb: [0.0; 3], + translation_z: 0.0, + range: screen_range, + }); + + let nebula_material = materials + .get(nebula_material_index) + .ok_or_else(|| "nebula material descriptor is unavailable".to_string())?; + let nebula_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(vertex.position, camera.position), + [color[0], color[1], color[2]], + color[3], + vertex.uv[0], + sky_normal(vertex.normal), + ) + }) + .collect(); + let nebula_range = append_environment_range( + mesh, + nebula_material_index, + nebula_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + nebula_material_index, + nebula_range.range_index, + ); + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::NebulaStars, + stage: Some(0), + secondary_stage: Some(1), + material_index: nebula_material_index, + diffuse_alpha: nebula_material.diffuse_alpha, + static_directional_rgb: nebula_material.directional_rgb, + static_additive_rgb: nebula_material.additive_rgb, + translation_z: 0.0, + range: nebula_range, + }); + + let dome_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(vertex.position, camera.position), + [color[0], color[1], color[2]], + color[3], + [0.0, 0.0], + sky_normal(vertex.normal), + ) + }) + .collect(); + let dome_range = append_environment_range( + mesh, + gradient_material_index, + dome_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::DomeGradient, + stage: None, + secondary_stage: None, + material_index: gradient_material_index, + diffuse_alpha: 1.0, + static_directional_rgb: [1.0; 3], + static_additive_rgb: [0.0; 3], + translation_z: 0.0, + range: dome_range, + }); + + let clouds_material_index = material_indices[2] + .ok_or_else(|| "cloud material selector is unavailable".to_string())?; + let clouds_material = materials + .get(clouds_material_index) + .ok_or_else(|| "cloud material descriptor is unavailable".to_string())?; + let clouds_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(add3(vertex.position, camera.position), [0.0, 0.0, -5000.0]), + [color[0], color[1], color[2]], + color[3], + vertex.uv[2], + sky_normal(vertex.normal), + ) + }) + .collect(); + let clouds_range = append_environment_range( + mesh, + clouds_material_index, + clouds_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + clouds_material_index, + clouds_range.range_index, + ); + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::Clouds, + stage: Some(2), + secondary_stage: None, + material_index: clouds_material_index, + diffuse_alpha: clouds_material.diffuse_alpha, + static_directional_rgb: clouds_material.directional_rgb, + static_additive_rgb: clouds_material.additive_rgb, + translation_z: -5000.0, + range: clouds_range, + }); + + let sprite_size = (camera.far_plane * 0.01).max(1.0); + for row in ENVIRONMENT_SPRITE_ROWS { + let material_index = material_indices[row] + .ok_or_else(|| format!("sky sprite row {row} has no material selector"))?; + let range = append_environment_range( + mesh, + material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + material_index, + range.range_index, + ); + scene.sprites.push(EnvironmentSpriteLayer { + row, + material_index, + diffuse_alpha: materials + .get(material_index) + .map_or(1.0, |material| material.diffuse_alpha), + flare_index: None, + range, + }); + } + + for flare_index in 0..ENVIRONMENT_FLARE_SLOTS { + let row = SKY_FLARE_ROWS[flare_index]; + let material_index = material_indices[row] + .ok_or_else(|| format!("sky flare row {row} has no material selector"))?; + let range = append_environment_range( + mesh, + material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::Additive, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + material_index, + range.range_index, + ); + scene.sprites.push(EnvironmentSpriteLayer { + row, + material_index, + diffuse_alpha: materials + .get(material_index) + .map_or(1.0, |material| material.diffuse_alpha), + flare_index: Some(flare_index), + range, + }); + } + + let particle_indices = quad_indices(ENVIRONMENT_PARTICLE_SLOTS)?; + scene.rain = append_environment_range( + mesh, + material_indices[8] + .ok_or_else(|| "rain material selector is unavailable".to_string())?, + placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), + particle_indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.rain.material_index, + scene.rain.range_index, + ); + scene.snow = append_environment_range( + mesh, + material_indices[7] + .ok_or_else(|| "snow material selector is unavailable".to_string())?, + placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), + particle_indices, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.snow.material_index, + scene.snow.range_index, + ); + scene.lightning = append_environment_range( + mesh, + lightning_material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.lightning.material_index, + scene.lightning.range_index, + ); + + // Preserve the native sky order while keeping the indexed mesh + // contiguous. The screen gradient must be the first draw, and the + // cloud dome follows all directional sprites. Reordering the range + // metadata alone would leave first_index values pointing into the old + // segment order, so move each index segment together with its range. + let range_for_kind = |kind: SkyPassKind| { + scene + .sky_layers + .iter() + .find(|layer| layer.kind == kind) + .map(|layer| layer.range.range_index) + }; + let screen_index = range_for_kind(SkyPassKind::ScreenGradient) + .ok_or_else(|| "screen gradient range was not allocated".to_string())?; + let nebula_index = range_for_kind(SkyPassKind::NebulaStars) + .ok_or_else(|| "nebula sky range was not allocated".to_string())?; + let dome_index = range_for_kind(SkyPassKind::DomeGradient) + .ok_or_else(|| "dome gradient range was not allocated".to_string())?; + let cloud_index = range_for_kind(SkyPassKind::Clouds) + .ok_or_else(|| "cloud sky range was not allocated".to_string())?; + let sprite_indices = scene + .sprites + .iter() + .map(|sprite| sprite.range.range_index) + .collect::>(); + if sprite_indices.is_empty() { + return Err("sky sprite ranges were not allocated".to_string()); + } + let rain_index = scene.rain.range_index; + let snow_index = scene.snow.range_index; + let lightning_index = scene.lightning.range_index; + let environment_indices = [ + screen_index, + nebula_index, + dome_index, + cloud_index, + rain_index, + snow_index, + lightning_index, + *shadow_range_index, + ] + .into_iter() + .chain(sprite_indices.iter().copied()) + .collect::>(); + let old_range_count = mesh.draw_ranges.len(); + let mut old_order = Vec::with_capacity(old_range_count); + old_order.push(screen_index); + old_order.extend((0..old_range_count).filter(|index| { + !environment_indices.contains(index) + && *index != screen_index + && *index != nebula_index + && *index != dome_index + && *index != cloud_index + })); + old_order.push(*shadow_range_index); + old_order.extend([nebula_index, dome_index]); + old_order.extend(sprite_indices.iter().copied()); + old_order.extend([cloud_index, rain_index, snow_index, lightning_index]); + let remap = reorder_draw_ranges(mesh, &old_order)?; + for layer in &mut scene.sky_layers { + layer.range.range_index = *remap + .get(layer.range.range_index) + .ok_or_else(|| "sky draw range remap is out of bounds".to_string())?; + } + for sprite in &mut scene.sprites { + sprite.range.range_index = *remap + .get(sprite.range.range_index) + .ok_or_else(|| "sky sprite draw range remap is out of bounds".to_string())?; + } + scene.rain.range_index = *remap + .get(scene.rain.range_index) + .ok_or_else(|| "rain draw range remap is out of bounds".to_string())?; + scene.snow.range_index = *remap + .get(scene.snow.range_index) + .ok_or_else(|| "snow draw range remap is out of bounds".to_string())?; + scene.lightning.range_index = *remap + .get(scene.lightning.range_index) + .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 range_index in sun_occlusion_range_indices { + *range_index = *remap + .get(*range_index) + .ok_or_else(|| "sun occlusion range remap is out of bounds".to_string())?; + } + *shadow_range_index = *remap + .get(*shadow_range_index) + .ok_or_else(|| "shadow draw range remap is out of bounds".to_string())?; + Ok(scene) + } + + fn update( + &mut self, + mesh: &mut VulkanStaticMesh, + materials: &[VulkanStaticMaterial], + renderer: &mut VulkanSmokeRenderer, + sky_frame: &SkyFrame, + sky_mesh: &SkyMesh, + environment_frame: &EnvironmentFrame, + camera: &FreeFlightCamera, + viewport: [f32; 2], + material_clock_ms: u32, + terrain: &TerrainWorld, + sun_occlusion_range_indices: &[usize], + ) -> Result<(), String> { + let active_materials = update_material_animations( + &mut self.material_animations, + materials, + renderer, + material_clock_ms, + )?; + // Resolve the native glare once for the frame. CSky feeds the + // boosted primary color back into the light manager and uses the + // positive boost delta for both the screen-gradient horizon floor and + // the dome palette clamp. + let projected_sun = + project_direction_pixels(camera, sky_frame.sun.geometry_direction(), viewport) + .unwrap_or([f32::NAN; 2]); + let optics = sky_frame + .sun_optics(SunVisibility { + projected_sun, + viewport, + view_forward: camera.forward(), + // Flares use the same terrain/object ray query as the native + // visibility gate. The sun sprite itself deliberately stays + // independent of this test, matching the native layer's + // `requires_occlusion` split. + unoccluded: native_sun_unoccluded_with_world( + terrain, + mesh, + sun_occlusion_range_indices, + camera, + sky_frame.sun.geometry_direction(), + ), + }) + .ok(); + let glare_rgb_delta = optics.map_or([0.0; 3], |optics| { + std::array::from_fn(|index| { + (optics.primary_color[index] - sky_frame.sun.color[index]).max(0.0) + }) + }); + let gradient = sky_frame.screen_gradient(camera.yaw, glare_rgb_delta); + if let Some(optics) = optics { + let mut uniforms = renderer.frame_uniforms(); + if sky_frame.sun.active { + uniforms.directional_lights[0].rgb = optics.primary_color; + } + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update glare light uniforms: {error}"))?; + } + let gradient_rgba = gradient.color.rgba(); + for layer in &self.sky_layers { + let range = layer.range; + let destination = mesh + .vertices + .get_mut(range.vertex_start..range.vertex_start + range.vertex_capacity) + .ok_or_else(|| "sky vertex allocation is out of bounds".to_string())?; + let pass_frame = sky_frame + .passes + .iter() + .find(|pass| pass.kind == layer.kind) + .ok_or_else(|| format!("sky frame is missing {:?} pass", layer.kind))?; + match layer.kind { + SkyPassKind::ScreenGradient => { + if destination.len() != 4 { + return Err("screen gradient vertex allocation changed".to_string()); + } + destination.copy_from_slice(&screen_gradient_quad_vertices( + camera, + viewport, + [gradient_rgba[0], gradient_rgba[1], gradient_rgba[2]], + )); + } + SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds => { + if destination.len() != sky_mesh.vertices.len() { + return Err( + "sky mesh vertex count changed after GPU allocation".to_string() + ); + } + for (target, source) in destination.iter_mut().zip(&sky_mesh.vertices) { + let source_color = match layer.kind { + // Native mode 4 receives a white DIFFUSE and + // chooses nebula/stars through TEX1 alpha. + SkyPassKind::NebulaStars | SkyPassKind::Clouds => [1.0; 4], + SkyPassKind::DomeGradient => { + gradient.clamp_dome_color(source.color).rgba() + } + SkyPassKind::ScreenGradient => unreachable!(), + }; + target.position = add3( + add3(source.position, camera.position), + [0.0, 0.0, layer.translation_z], + ); + target.color = [source_color[0], source_color[1], source_color[2]]; + target.uv = layer + .stage + .and_then(|stage| source.uv.get(stage).copied()) + .unwrap_or([0.0, 0.0]); + target.detail_uv = layer + .secondary_stage + .and_then(|stage| source.uv.get(stage).copied()) + .unwrap_or([0.0, 0.0]); + target.normal = sky_normal(source.normal); + target.overlay_alpha = source_color[3]; + } + } + } + let material_index = active_materials + .get(&range.range_index) + .map(|state| state.material_index) + .unwrap_or(layer.material_index); + if !active_materials.contains_key(&range.range_index) { + renderer + .set_material_alphas(material_index, layer.diffuse_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky material alpha: {error}"))?; + } + if layer.kind == SkyPassKind::Clouds { + let directional_rgb = pass_frame + .material_color + .unwrap_or(layer.static_directional_rgb); + renderer + .set_material_lighting( + material_index, + directional_rgb, + layer.static_additive_rgb, + ) + .map_err(|error| format!("update cloud material lighting: {error}"))?; + } + renderer + .set_draw_range_index_count( + range.range_index, + if layer.kind == SkyPassKind::ScreenGradient { + 6 + } else { + u32::try_from(sky_mesh.indices.len()) + .map_err(|_| "sky index count exceeds u32".to_string())? + }, + ) + .map_err(|error| format!("update sky draw range: {error}"))?; + } + + // Flares share their source material row, so their packed alpha lives + // in each vertex. Upload the shared phase alpha once before emitting + // the twelve fixed-capacity ranges. + let mut flare_materials = HashMap::new(); + for sprite in &self.sprites { + if sprite.flare_index.is_some() + && !active_materials.contains_key(&sprite.range.range_index) + { + flare_materials.insert(sprite.material_index, sprite.diffuse_alpha); + } + } + for (material_index, diffuse_alpha) in flare_materials { + renderer + .set_material_alphas(material_index, diffuse_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky flare alpha: {error}"))?; + } + for sprite in &self.sprites { + let mut active = false; + let mut vertices = None; + let mut material_alpha = 0.0; + if let Some(flare_index) = sprite.flare_index { + if let Some(flare) = + optics.and_then(|optics| optics.flares.get(flare_index).copied()) + { + let direction = screen_ray_from_pixels(camera, flare.center_pixels, viewport); + active = flare.active && direction.is_some() && flare.color[3] > 0.0; + if let Some(direction) = direction { + vertices = Some(sprite_quad_vertices_pixels( + camera, + direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + flare.half_size_pixels, + flare.half_size_pixels, + viewport, + [flare.color[0], flare.color[1], flare.color[2]], + flare.color[3], + flare.uv, + )); + } + } + } else if let Some(frame) = sprite_frame_for_row(sky_frame, sprite.row) { + let projected = project_direction_pixels(camera, frame.direction, viewport); + active = frame.active + && frame.intensity > 0.0 + && projected.is_some_and(|center| { + center[0] >= 0.0 + && center[0] <= viewport[0] + && center[1] >= 0.0 + && center[1] <= viewport[1] + }); + if active { + if let Ok([half_width, half_height]) = sprite_half_size_pixels( + frame.size_values, + viewport, + horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)), + ) { + vertices = Some(sprite_quad_vertices_pixels( + camera, + frame.direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + half_width, + half_height, + viewport, + frame.color, + frame.alpha, + frame.uv, + )); + } else { + active = false; + } + } + material_alpha = if active { + // The packed sprite alpha is carried by the vertex. The + // MAT0 phase alpha remains the material multiplier; + // intensity controls lifecycle visibility, not a second + // alpha fade. + sprite.diffuse_alpha + } else { + 0.0 + }; + } + if active { + if let Some(vertices) = vertices { + let destination = mesh + .vertices + .get_mut(sprite.range.vertex_start..sprite.range.vertex_start + 4) + .ok_or_else(|| { + "sky sprite vertex allocation is out of bounds".to_string() + })?; + destination.copy_from_slice(&vertices); + } else { + active = false; + } + } + if sprite.flare_index.is_none() { + let material_index = active_materials + .get(&sprite.range.range_index) + .map(|state| state.material_index) + .unwrap_or(sprite.material_index); + // The animation update above has already restored the + // sampled phase alpha for an active animated material. A + // hidden sprite still needs an explicit zero, while a static + // sprite uses its authored material alpha when visible. + let should_write_alpha = + !active || !active_materials.contains_key(&sprite.range.range_index); + if should_write_alpha { + renderer + .set_material_alphas(material_index, material_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky sprite alpha: {error}"))?; + } + } + renderer + .set_draw_range_index_count(sprite.range.range_index, if active { 6 } else { 0 }) + .map_err(|error| format!("update sky sprite draw range: {error}"))?; + } + + let mut rain_count = 0usize; + let mut snow_count = 0usize; + let mut lightning = None; + for primitive in &environment_frame.primitives { + match primitive { + EnvironmentPrimitive::Particle { + kind, + color, + screen, + .. + } => { + let (range, slot) = match kind { + PrecipitationKind::Rain if rain_count < ENVIRONMENT_PARTICLE_SLOTS => { + let slot = rain_count; + rain_count += 1; + (self.rain, slot) + } + PrecipitationKind::Snow if snow_count < ENVIRONMENT_PARTICLE_SLOTS => { + let slot = snow_count; + snow_count += 1; + (self.snow, slot) + } + _ => continue, + }; + let vertices = + precipitation_quad_vertices(*kind, screen, *color, camera, viewport); + let start = range.vertex_start + slot * 4; + let destination = mesh.vertices.get_mut(start..start + 4).ok_or_else(|| { + "precipitation vertex allocation is out of bounds".to_string() + })?; + destination.copy_from_slice(&vertices); + } + EnvironmentPrimitive::Lightning(bolt) if lightning.is_none() => { + lightning = Some(bolt); + } + EnvironmentPrimitive::Lightning(_) => {} + } + } + renderer + .set_draw_range_index_count( + self.rain.range_index, + u32::try_from(rain_count * 6) + .map_err(|_| "rain draw count exceeds u32".to_string())?, + ) + .map_err(|error| format!("update rain draw range: {error}"))?; + renderer + .set_draw_range_index_count( + self.snow.range_index, + u32::try_from(snow_count * 6) + .map_err(|_| "snow draw count exceeds u32".to_string())?, + ) + .map_err(|error| format!("update snow draw range: {error}"))?; + + let lightning_alpha = if let Some(bolt) = lightning { + let vertices = lightning_quad_vertices(bolt); + let destination = mesh + .vertices + .get_mut(self.lightning.vertex_start..self.lightning.vertex_start + 4) + .ok_or_else(|| "lightning vertex allocation is out of bounds".to_string())?; + destination.copy_from_slice(&vertices); + bolt.intensity.clamp(0.0, 1.0) + } else { + 0.0 + }; + let lightning_material_index = active_materials + .get(&self.lightning.range_index) + .map(|state| state.material_index) + .unwrap_or(self.lightning_material_index); + renderer + .set_material_alphas(lightning_material_index, lightning_alpha, 0.0) + .map_err(|error| format!("update lightning material alpha: {error}"))?; + renderer + .set_draw_range_index_count( + self.lightning.range_index, + if lightning.is_some() { 6 } else { 0 }, + ) + .map_err(|error| format!("update lightning draw range: {error}"))?; + Ok(()) + } +} + +fn sprite_frame_for_row<'frame, 'materials>( + sky_frame: &'frame SkyFrame<'materials>, + row: usize, +) -> Option> { + sky_frame + .sprites + .iter() + .copied() + .find(|sprite| sprite.row == row) +} + +fn horizontal_fov(camera: &FreeFlightCamera, aspect: f32) -> f32 { + let aspect = if aspect.is_finite() && aspect > 0.0 { + aspect + } else { + 16.0 / 9.0 + }; + 2.0 * ((camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan() * aspect).atan() +} + +/// Tests the camera-to-sun segment against the validated mission terrain. +/// +/// The native CSun caller starts a half-unit in front of the camera along the +/// normalized geometry direction, then queries the finite segment ending at +/// the sun center. `Land.msh` stores Z in the native 32x height unit while the +/// renderer's world projection divides it by 32, so only the Z components are +/// scaled before the terrain query. The ray parameter remains normalized to +/// the finite segment and is therefore independent of world distance. +#[cfg(test)] +fn native_sun_unoccluded( + terrain: &TerrainWorld, + camera: &FreeFlightCamera, + direction: [f32; 3], +) -> bool { + let empty_mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: Vec::new(), + draw_ranges: Vec::new(), + }; + native_sun_unoccluded_with_world(terrain, &empty_mesh, &[], camera, direction) +} + +fn native_sun_unoccluded_with_world( + terrain: &TerrainWorld, + world_mesh: &VulkanStaticMesh, + sun_occlusion_range_indices: &[usize], + camera: &FreeFlightCamera, + direction: [f32; 3], +) -> bool { + // Native CSun requests object kinds 1, 3, 4, and 10 (the aggregate + // object mask is 0x41A) and excludes full surface flag 0x20. The preview + // range list is built from roots whose raw mission kind is one of those + // four eligible kinds; fixed atmosphere ranges are remapped out before + // this call. + let Some(direction) = normalize3(direction) else { + return false; + }; + let sun_center = add3( + camera.position, + scale3( + direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + ), + ); + let start = add3(camera.position, scale3(direction, 0.5)); + let segment = sub3(sun_center, start); + if !sun_center + .iter() + .chain(start.iter()) + .chain(segment.iter()) + .all(|value| value.is_finite()) + { + return false; + } + if dot3(segment, segment) <= f32::EPSILON { + return true; + } + let origin = [start[0], start[1], start[2] * 32.0]; + let terrain_segment = [segment[0], segment[1], segment[2] * 32.0]; + if let Ok(Some(hit)) = terrain.raycast_excluding( + origin, + terrain_segment, + FullSurfaceMask(0), + FullSurfaceMask(0x0000_0020), + ) { + if hit.distance.is_finite() && hit.distance <= 1.0 { + return false; + } + } + !world_mesh_sun_occluded(world_mesh, sun_occlusion_range_indices, start, segment) +} + +fn world_mesh_sun_occluded( + mesh: &VulkanStaticMesh, + range_indices: &[usize], + origin: [f32; 3], + direction: [f32; 3], +) -> bool { + for &range_index in range_indices { + let Some(range) = mesh.draw_ranges.get(range_index).copied() else { + continue; + }; + // The native world query receives the compact exclusion bit 0x8, + // which is the world-space representation of full surface flag 0x20. + if range.batch_flags & 0x0008 != 0 { + continue; + } + let Ok(first_index) = usize::try_from(range.first_index) else { + continue; + }; + let Ok(index_count) = usize::try_from(range.index_count) else { + continue; + }; + let Some(indices) = mesh + .indices + .get(first_index..first_index.saturating_add(index_count)) + else { + continue; + }; + for triangle in indices.chunks_exact(3) { + let Ok(a) = usize::try_from(triangle[0]) else { + continue; + }; + let Ok(b) = usize::try_from(triangle[1]) else { + continue; + }; + let Ok(c) = usize::try_from(triangle[2]) else { + continue; + }; + let (Some(&a), Some(&b), Some(&c)) = ( + mesh.vertices.get(a), + mesh.vertices.get(b), + mesh.vertices.get(c), + ) else { + continue; + }; + if segment_intersects_triangle(origin, direction, a.position, b.position, c.position) { + return true; + } + } + } + false +} + +fn segment_intersects_triangle( + origin: [f32; 3], + direction: [f32; 3], + a: [f32; 3], + b: [f32; 3], + c: [f32; 3], +) -> bool { + let edge1 = sub3(b, a); + let edge2 = sub3(c, a); + let pvec = cross3(direction, edge2); + let determinant = dot3(edge1, pvec); + if !determinant.is_finite() || determinant.abs() <= f32::EPSILON { + return false; + } + let inverse = determinant.recip(); + let tvec = sub3(origin, a); + let u = dot3(tvec, pvec) * inverse; + if !u.is_finite() || !(0.0..=1.0).contains(&u) { + return false; + } + let qvec = cross3(tvec, edge1); + let v = dot3(direction, qvec) * inverse; + if !v.is_finite() || v < 0.0 || u + v > 1.0 { + return false; + } + let distance = dot3(edge2, qvec) * inverse; + 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. +fn first_indexed_vertex_distance( + mesh: &VulkanStaticMesh, + range_index: usize, + camera_position: [f32; 3], +) -> Result { + let range: VulkanStaticDrawRange = *mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let first_index = usize::try_from(range.first_index) + .map_err(|_| "world draw range first index is not addressable".to_string())?; + let vertex_index = + usize::try_from(*mesh.indices.get(first_index).ok_or_else(|| { + "world draw range first index is outside the index buffer".to_string() + })?) + .map_err(|_| "world draw range vertex index is not addressable".to_string())?; + let vertex = mesh + .vertices + .get(vertex_index) + .ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?; + let delta = sub3(vertex.position, camera_position); + let distance = dot3(delta, delta).sqrt(); + distance + .is_finite() + .then_some(distance) + .ok_or_else(|| "world draw range sort distance is non-finite".to_string()) +} + +fn project_direction_pixels( + camera: &FreeFlightCamera, + direction: [f32; 3], + viewport: [f32; 2], +) -> Option<[f32; 2]> { + let direction = normalize3(direction)?; + let width = viewport[0]; + let height = viewport[1]; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + let depth = dot3(camera.forward(), direction); + if !depth.is_finite() || depth <= camera.near_plane.max(0.001) { + return None; + } + let aspect = width / height; + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal); + let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical); + let pixels = [(ndc_x * 0.5 + 0.5) * width, (0.5 - ndc_y * 0.5) * height]; + pixels + .iter() + .all(|value| value.is_finite()) + .then_some(pixels) +} + +fn screen_ray_from_pixels( + camera: &FreeFlightCamera, + pixels: [f32; 2], + viewport: [f32; 2], +) -> Option<[f32; 3]> { + if !pixels.iter().all(|value| value.is_finite()) + || !viewport.iter().all(|value| value.is_finite()) + || viewport[0] <= 0.0 + || viewport[1] <= 0.0 + { + return None; + } + let aspect = viewport[0] / viewport[1]; + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0; + let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0; + normalize3(add3( + add3( + camera.forward(), + scale3(camera.right(), ndc_x * half_horizontal), + ), + scale3(camera.up(), ndc_y * half_vertical), + )) +} + +fn sprite_quad_vertices_pixels( + camera: &FreeFlightCamera, + direction: [f32; 3], + distance: f32, + half_width_pixels: f32, + half_height_pixels: f32, + viewport: [f32; 2], + color: [f32; 3], + alpha: f32, + uv: [[f32; 2]; 4], +) -> [VulkanStaticVertex; 4] { + let direction = normalize3(direction).unwrap_or(camera.forward()); + let center = add3(camera.position, scale3(direction, distance)); + let depth = dot3(camera.forward(), direction) + .mul_add(distance, 0.0) + .max(camera.near_plane.max(0.001)); + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let aspect = width / height; + let vertical_fov = camera.vertical_fov.clamp(0.05, 3.0); + let horizontal_fov_radians = horizontal_fov(camera, aspect); + let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width; + let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height; + let right = scale3( + camera.right(), + half_width_pixels.max(0.0) * world_per_pixel_x, + ); + let up = scale3(camera.up(), half_height_pixels.max(0.0) * world_per_pixel_y); + // Native sun and flare UVs are ordered left-top, left-bottom, right-bottom, + // right-top. The shared index order is bottom-left, bottom-right, top-left, + // top-right. + let uv = [uv[1], uv[2], uv[0], uv[3]]; + [ + environment_vertex_with_alpha(sub3(sub3(center, right), up), color, alpha, uv[0]), + environment_vertex_with_alpha(add3(sub3(center, up), right), color, alpha, uv[1]), + environment_vertex_with_alpha(add3(sub3(center, right), up), color, alpha, uv[2]), + environment_vertex_with_alpha(add3(add3(center, right), up), color, alpha, uv[3]), + ] +} + +fn precipitation_quad_vertices( + kind: PrecipitationKind, + screen: &ScreenBillboard, + color: [f32; 4], + camera: &FreeFlightCamera, + viewport: [f32; 2], +) -> [VulkanStaticVertex; 4] { + let rgb = [color[0], color[1], color[2]]; + let depths = match kind { + PrecipitationKind::Rain => [ + screen.tail_depth, + screen.tail_depth, + screen.head_depth, + screen.head_depth, + ], + PrecipitationKind::Snow => [screen.head_depth; 4], + }; + std::array::from_fn(|index| { + environment_vertex_with_alpha( + world_from_ndc(camera, screen.corners[index], depths[index], viewport), + rgb, + color[3], + screen.uv[index], + ) + }) +} + +fn world_from_ndc( + camera: &FreeFlightCamera, + ndc: [f32; 2], + depth: f32, + viewport: [f32; 2], +) -> [f32; 3] { + let depth = if depth.is_finite() { + depth.max(camera.near_plane.max(0.001)) + } else { + camera.near_plane.max(0.001) + }; + let aspect = viewport[0].max(1.0) / viewport[1].max(1.0); + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let offset = add3( + scale3(camera.right(), ndc[0] * half_horizontal), + scale3(camera.up(), ndc[1] * half_vertical), + ); + add3( + camera.position, + scale3(add3(camera.forward(), offset), depth), + ) +} + +fn lightning_quad_vertices( + bolt: &fparkan_fx::environment::LightningBolt, +) -> [VulkanStaticVertex; 4] { + std::array::from_fn(|index| { + environment_vertex(bolt.quad.vertices[index], [1.0; 3], bolt.quad.uv[index]) + }) +} + +/// CPU atmosphere state shared by the interactive preview and the renderer's +/// 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. +struct DynamicEnvironment { + schedule: TypedAtmosphere, + sky: SkySystem, + environment: EnvironmentSystem, + materials: SkyMaterials, + material_assets: HashMap, + schedule_offset_seconds: f32, + time_seconds: f32, +} + +impl DynamicEnvironment { + fn update( + &mut self, + dt_seconds: f32, + camera: &FreeFlightCamera, + aspect: f32, + viewport: [f32; 2], + renderer: &mut VulkanSmokeRenderer, + mut audio: Option<&mut audio::GameAudio>, + ) -> Result<(SkyFrame<'_>, SkyMesh, EnvironmentFrame), String> { + let dt_seconds = if dt_seconds.is_finite() { + dt_seconds.max(0.0) + } else { + 0.0 + }; + self.time_seconds = if self.time_seconds.is_finite() { + self.time_seconds + dt_seconds + } else { + 0.0 + }; + let absolute_time_seconds = self.schedule_offset_seconds + self.time_seconds; + let atmosphere = self.schedule.sample(absolute_time_seconds); + let mut sky_mesh = self.sky.mesh().clone(); + let sky_frame = + SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds); + sky_mesh.update_colors(&sky_frame.sky); + if let Some(audio) = audio.as_mut() { + let primary_rgb = if sky_frame.sun.active { + sky_frame.sun.color + } else if sky_frame.moon.active { + sky_frame.moon.color + } else { + [0.0; 3] + }; + audio + .update_ambience(dt_seconds, primary_rgb) + .map_err(|error| format!("update ambient variation: {error}"))?; + } + + let mut uniforms = renderer.frame_uniforms(); + uniforms.clip_from_world = camera.vulkan_camera(aspect).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; + uniforms.fog_color = sky_frame.fog.color; + 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 = FxCamera::with_projection( + camera.position, + camera.forward(), + camera.right(), + camera.up(), + camera.vertical_fov, + aspect, + ) + .with_viewport(viewport); + let environment_frame = self.environment.update_atmosphere_with_materials( + dt_seconds, + &atmosphere, + &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 + .handle_sound_event(sound) + .map_err(|error| format!("play environment sound: {error}"))?; + } + } + Ok((sky_frame, sky_mesh, environment_frame)) + } +} + +fn load_environment( + root: &Path, + mission: &str, + atmosphere_seconds: Option, +) -> Result, String> { + let mission_dir = mission_asset_directory(root, mission)?; + let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else { + return Ok(None); + }; + let sky_bytes = std::fs::read(&sky_path) + .map_err(|error| format!("read atmosphere {}: {error}", sky_path.display()))?; + let schedule = TypedAtmosphere::parse(&sky_bytes) + .map_err(|error| format!("parse atmosphere {}: {error}", sky_path.display()))?; + + let sky_wea_path = match find_sibling_file(&sky_path, "sky.wea") { + Some(path) => Some(path), + None => find_mission_file(&mission_dir, "sky.wea")?, + } + .ok_or_else(|| format!("selected atmosphere {} has no sky.wea", sky_path.display()))?; + let materials = SkyMaterials::parse( + &std::fs::read(&sky_wea_path) + .map_err(|error| format!("read sky materials {}: {error}", sky_wea_path.display()))?, + ) + .map_err(|error| format!("parse sky materials {}: {error}", sky_wea_path.display()))?; + let mut sky_material_loader = StandaloneWearMaterialLoader::new(root); + let mut material_assets = + load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root( + root, + &sky_wea_path, + 0, + ) + .map_err(|error| { + format!( + "resolve sky material WEAR {}: {error}", + sky_wea_path.display() + ) + })? + .into_iter() + .enumerate() + .map( + |(material_index, (name, mips, phase, document))| -> Result<_, String> { + let initial_phase = phase; + let mut phase_textures = Vec::with_capacity(document.phases.len()); + let mut phases = Vec::with_capacity(document.phases.len()); + for phase_index in 0..document.phases.len() { + let phase_index_u16 = u16::try_from(phase_index).unwrap_or(u16::MAX); + let (phase_mips, sampled_phase) = if phase_index == 0 { + (mips.clone(), initial_phase) + } else { + sky_material_loader + .load( + &sky_wea_path, + u16::try_from(material_index).unwrap_or(u16::MAX), + phase_index_u16, + ) + .map_err(|error| { + format!("resolve sky material phase {phase_index}: {error}") + })? + }; + phase_textures.push(vulkan_texture_from_rgba_mips(phase_mips, "sky material")?); + phases.push(sampled_phase); + } + let texture = phase_textures + .first() + .cloned() + .ok_or_else(|| "sky material has no MAT0 phases".to_string())?; + Ok(( + String::from_utf8_lossy(&name).into_owned(), + EnvironmentMaterialAsset { + texture, + phase: initial_phase, + document, + phase_textures, + phases, + }, + )) + }, + ) + .collect::, String>>()?; + let effect_bytes = load_resource_entry_bytes_from_root(root, "effects.rlb", "env_lightning") + .map_err(|error| format!("load effects.rlb/env_lightning: {error}"))?; + let lightning_effect = decode_env_lightning_fxid(effect_bytes) + .map_err(|error| format!("decode effects.rlb/env_lightning: {error}"))?; + if !lightning_effect + .visual + .archive + .eq_ignore_ascii_case("material.lib") + { + return Err(format!( + "env_lightning visual archive must be material.lib, got {:?}", + lightning_effect.visual.archive + )); + } + let (lightning_mips, lightning_phase, lightning_document) = + load_material_name_texture_rgba8_and_phase_with_document_from_root( + root, + &lightning_effect.visual.name, + 0, + ) + .map_err(|error| { + format!( + "resolve env_lightning MAT0 {}: {error}", + lightning_effect.visual.name + ) + })?; + let mut lightning_phase_textures = vec![vulkan_texture_from_rgba_mips( + lightning_mips, + "env_lightning material", + )?]; + let mut lightning_phases = vec![lightning_phase]; + for phase_index in 1..lightning_document.phases.len() { + let (phase_mips, phase, _document) = + load_material_name_texture_rgba8_and_phase_with_document_from_root( + root, + &lightning_effect.visual.name, + u16::try_from(phase_index).unwrap_or(u16::MAX), + ) + .map_err(|error| { + format!( + "resolve env_lightning MAT0 phase {phase_index} {}: {error}", + lightning_effect.visual.name + ) + })?; + lightning_phase_textures.push(vulkan_texture_from_rgba_mips( + phase_mips, + "env_lightning material", + )?); + lightning_phases.push(phase); + } + let lightning_texture = lightning_phase_textures + .first() + .cloned() + .ok_or_else(|| "env_lightning MAT0 has no phases".to_string())?; + material_assets.insert( + lightning_effect.visual.name.clone(), + EnvironmentMaterialAsset { + texture: lightning_texture, + phase: lightning_phase, + document: lightning_document, + phase_textures: lightning_phase_textures, + phases: lightning_phases, + }, + ); + let initial_offset_seconds = schedule.initial_offset_seconds() as f32; + let sky = SkySystem::new(Default::default(), materials.clone()) + .map_err(|error| format!("build sky geometry: {error}"))?; + let time_seconds = atmosphere_seconds + .map(|seconds| seconds - initial_offset_seconds) + .unwrap_or(0.0); + let mut environment = EnvironmentSystem::new(0x4650_4152_4B41_4E_u64); + environment.set_lightning_effect(lightning_effect); + Ok(Some(DynamicEnvironment { + schedule, + sky, + environment, + materials, + material_assets, + schedule_offset_seconds: initial_offset_seconds, + time_seconds, + })) +} + +/// Returns the host directory containing the selected mission's assets. +/// Mission keys are the same relative paths accepted by the runtime VFS; the +/// host root is only used here for the small set of loose mission-local files +/// that are not archive entries (`sky.ske` and `sky.wea`). +fn mission_asset_directory(root: &Path, mission: &str) -> Result { + let mission_path = Path::new(mission); + if mission_path.is_absolute() { + return Err("mission environment path must be relative to --root".to_string()); + } + let directory = mission_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let directory = root.join(directory); + if !directory.is_dir() { + return Err(format!( + "selected mission asset directory does not exist: {}", + directory.display() + )); + } + Ok(directory) +} + +/// Finds one mission-local loose environment file. A direct sibling wins; +/// nested `env`, `environment`, or `planet` layouts are accepted only when +/// they contain a single matching file, so a second unrelated candidate never +/// changes the selected mission silently. +fn find_mission_file(root: &Path, name: &str) -> Result, String> { + let direct = root.join(name); + if direct.is_file() { + return Ok(Some(direct)); + } + let mut stack = vec![root.to_path_buf()]; + let mut matches = Vec::new(); + while let Some(directory) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + stack.push(path); + } else if file_type.is_file() + && path + .file_name() + .is_some_and(|file_name| file_name.eq_ignore_ascii_case(name)) + { + matches.push(path); + } + } + } + matches.sort(); + match matches.as_slice() { + [] => Ok(None), + [path] => Ok(Some(path.clone())), + _ => Err(format!( + "selected mission has multiple {name} files: {}", + matches + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + )), + } +} + +fn find_sibling_file(source: &Path, name: &str) -> Option { + source + .parent() + .map(|parent| parent.join(name)) + .filter(|path| path.is_file()) +} + +fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +fn add3_in_place(target: &mut [f32; 3], value: [f32; 3]) { + *target = add3(*target, value); +} + +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] { + [value[0] * factor, value[1] * factor, value[2] * factor] +} + +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])) +} + +fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn length3(value: [f32; 3]) -> Option { + let length = dot3(value, value).sqrt(); + length + .is_finite() + .then_some(length) + .filter(|length| *length > f32::EPSILON) +} + +fn normalize3(value: [f32; 3]) -> Option<[f32; 3]> { + let inverse = length3(value)?.recip(); + Some(scale3(value, inverse)) +} + +fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { + let mut result = [0.0; 16]; + for row in 0..4 { + for column in 0..4 { + result[row * 4 + column] = (0..4) + .map(|inner| left[row * 4 + inner] * right[inner * 4 + column]) + .sum(); + } + } + result +} + struct StaticPreviewScene { mesh: VulkanStaticMesh, materials: Vec, + material_animations: Vec, + sun_occlusion_range_indices: Vec, + shadow_casters: Vec, + shadow_receivers: Vec, + shadow_material_index: usize, + terrain: Arc, camera: VulkanStaticCamera, + free_camera: Option, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, } -/// Projects the mission terrain plus every MSH component of the explicitly -/// bounded static-preview roots into one diagnostic XY frame. +#[derive(Clone, Copy, Debug, Default)] +struct StartupTimings { + mission_assets: Duration, + terrain_materials: Duration, + sky: Duration, + gpu_initialization: Duration, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ShadowFrameEvidence { + visible_casters: usize, + projected_indices: usize, +} + +fn elapsed_ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + +fn unit_component_is_visible(index: usize, source_type: Option) -> bool { + index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO) +} + +fn native_sun_object_kind_eligible(source_type: Option) -> bool { + native_shadow_kind(source_type).is_some() +} + +const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT"); +const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); +const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT"); + +/// Maps the resolved registry type to the native object-kind selector used by +/// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the +/// renderer receives the registry provenance from the prepared visual. +fn native_shadow_kind(source_type: Option) -> Option { + match source_type { + Some(PROTOTYPE_TYPE_FORT) => Some(3), + Some(PROTOTYPE_TYPE_BTLU) => Some(4), + Some(PROTOTYPE_TYPE_STAT) => Some(10), + _ => None, + } +} + +fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose { + let rotated = rotate_unit_vector(child.translation, parent.rotation); + VulkanNodePose { + translation: [ + parent.translation[0] + rotated[0], + parent.translation[1] + rotated[1], + parent.translation[2] + rotated[2], + ], + rotation: multiply_unit_quaternions(parent.rotation, child.rotation), + } +} + +fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { + let [x, y, z, w] = rotation; + let tx = 2.0 * (y * value[2] - z * value[1]); + let ty = 2.0 * (z * value[0] - x * value[2]); + let tz = 2.0 * (x * value[1] - y * value[0]); + [ + value[0] + w * tx + (y * tz - z * ty), + value[1] + w * ty + (z * tx - x * tz), + value[2] + w * tz + (x * ty - y * tx), + ] +} + +fn multiply_unit_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { + let [lx, ly, lz, lw] = left; + let [rx, ry, rz, rw] = right; + [ + lw * rx + lx * rw + ly * rz - lz * ry, + lw * ry - lx * rz + ly * rw + lz * rx, + lw * rz + lx * ry - ly * rx + lz * rw, + lw * rw - lx * rx - ly * ry - lz * rz, + ] +} + +/// Projects the mission terrain plus every MSH component of the selected roots +/// into one shared world-space mesh. /// -/// This intentionally takes only the first MAT0 texture request for each MSH -/// batch selector. Terrain maps its proven low-byte packed tag channel through -/// the map-local `Land2.wea` table. The high-byte `Land1.wea` channel is -/// retained on draw ranges but awaits a recovered blend equation; orientation, -/// later material phases, animation, lightmaps and gameplay visibility remain -/// outside this bridge. +/// Terrain resolves its packed low-byte base and high-byte overlay through +/// map-local `Land1.wea`, then pairs each layer with its `Land2.wea` +/// microtexture and type-18 UV stream. Type-14 alpha remains a continuous +/// interpolated overlay mask. MSH component hierarchy, animation sampling, +/// lightmaps, and gameplay visibility are handled by the preview bridge below. + +fn static_preview_node_times( + node_count: usize, + bindings: &[PreparedControlNodeBinding], +) -> Result>, String> { + let mut node_times = vec![Some(AnimationTime(0.0)); node_count]; + for binding in bindings { + // Disabled rows are filtered while loading CTLD, but preserve the + // native guard here in case a caller constructs MissionAssets itself. + if binding.flags & 4 != 0 { + continue; + } + if binding.node_index >= node_count { + return Err(format!( + "control binding node {} is outside model node count {node_count}", + binding.node_index + )); + } + if !binding.frame_a.is_finite() + || !binding.frame_b.is_finite() + || !binding.initial_blend.is_finite() + || binding.frame_a < 0.0 + || binding.frame_b < 0.0 + { + return Err(format!( + "control binding for node {} contains an invalid frame or blend", + binding.node_index + )); + } + + let mut blend = binding.initial_blend; + if binding.flags & 1 != 0 { + if blend > 1.0 { + blend -= 1.0; + } + if blend < 0.0 { + blend += 1.0; + } + } else { + blend = blend.clamp(0.0, 1.0); + } + if binding.flags & 2 != 0 { + blend = 1.0 - blend; + } + let sample_time = (1.0 - blend) * binding.frame_a + blend * binding.frame_b; + if !sample_time.is_finite() || sample_time < 0.0 { + return Err(format!( + "control binding for node {} produced a negative or non-finite sample time unsupported by the static sampler", + binding.node_index + )); + } + // Source order is significant: native initialization writes each + // enabled row in turn, so a later row for the same node wins. + node_times[binding.node_index] = Some(AnimationTime(sample_time)); + } + Ok(node_times) +} + +fn static_preview_component_pose_buffer( + model: &ModelAsset, + bindings: &[PreparedControlNodeBinding], + static_animation_frame: Option, +) -> Result, String> { + if let Some(frame) = static_animation_frame { + return Ok(node38_sampled_hierarchy(model, frame)); + } + let node_times = static_preview_node_times(model.node_count, bindings)?; + Ok(node38_sampled_hierarchy_at_times(model, &node_times) + .or_else(|| node38_fallback_hierarchy(model))) +} fn static_preview_mesh_and_materials( assets: &MissionAssets, @@ -141,197 +3528,373 @@ fn static_preview_mesh_and_materials( let terrain_mesh = terrain .source_mesh() .ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?; - let xy_frame = legacy_camera - .is_none() - .then(|| static_preview_xy_frame(assets, terrain, roots)) - .transpose()?; let mut mesh = VulkanStaticMesh { vertices: Vec::new(), indices: Vec::new(), draw_ranges: Vec::new(), }; let mut materials = Vec::new(); - let terrain_component = if legacy_camera.is_some() { - project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) - } else { - let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; - project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame) - } - .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; + let mut material_animations = Vec::new(); + let mut sun_occlusion_range_indices = Vec::new(); + let mut shadow_casters = Vec::new(); + let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?; + let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root); + let terrain_component = project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) + .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; let terrain_materials = static_preview_terrain_base_materials( + &mut terrain_material_loader, root, land_msh_path, &terrain_component, &mut materials, + &mut material_animations, + static_material_phase, + )?; + append_static_preview_component( + &mut mesh, + terrain_component, + &terrain_materials, + Some(&mut material_animations), )?; - append_static_preview_component(&mut mesh, terrain_component, &terrain_materials)?; let mut mesh_components = 0; - for (object_index, root) in roots.iter().enumerate() { - for visual_id in assets.visuals_for_object(object_index) { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!( - "static preview root {object_index} references unknown visual {visual_id:?}" - ) - })?; - let Some(model_id) = visual.model_id else { + for (object_index, mission_root) in roots.iter().enumerate() { + let visual_ids = assets.visuals_for_object(object_index); + let records = &mission_root.unit_components; + let tree = if records.is_empty() { + None + } else { + Some(unit_component_tree(records).map_err(|error| { + format!("decode unit component tree for mission object {object_index}: {error}") + })?) + }; + if let Some(tree) = tree.as_ref() { + if tree.len() != visual_ids.len() { + return Err(format!( + "mission object {object_index} unit tree has {} records but {} visuals", + tree.len(), + visual_ids.len() + )); + } + } + let component_refs = visual_ids + .iter() + .map(|visual_id| { + let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { + format!( + "static preview root {object_index} references unknown visual {visual_id:?}" + ) + })?; + let model = visual + .model_id + .map(|model_id| { + assets.model_by_id(model_id).ok_or_else(|| { + format!( + "static preview visual {visual_id:?} references unknown model {model_id:?}" + ) + }) + }) + .transpose()?; + Ok((visual, model)) + }) + .collect::, String>>()?; + let component_controls = assets.object_component_controls.get(object_index); + if component_controls.is_some_and(|controls| controls.len() != component_refs.len()) { + return Err(format!( + "mission object {object_index} has {} visuals but {} component controls", + component_refs.len(), + component_controls.map_or(0, Vec::len) + )); + } + let root_native_kind = component_refs + .first() + .and_then(|(visual, _)| native_shadow_kind(visual.source_type)); + let root_sun_eligible = native_sun_object_kind_eligible( + component_refs + .first() + .and_then(|(visual, _)| visual.source_type), + ); + let mut root_shadow_triangles = Vec::new(); + let mut root_shadow_spheres = Vec::new(); + let mut component_mount_poses = vec![None; component_refs.len()]; + let mut component_pose_buffers = vec![None; component_refs.len()]; + for (component_index, (visual, model)) in component_refs.iter().enumerate() { + let Some(model) = *model else { continue; }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - let transform = LegacyIron3dEulerTransform { - translation: root.position, - orientation_radians: root.orientation_raw, - }; - let component = if legacy_camera.is_some() { - if let Some(frame) = static_animation_frame { - project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( - &model.validated, - transform, - root.scale, - frame, - ) + let component_bindings = component_controls + .and_then(|controls| controls.get(component_index)) + .and_then(Option::as_ref) + .map_or(&[][..], |control| control.bindings.as_slice()); + component_pose_buffers[component_index] = static_preview_component_pose_buffer( + &model.validated, + component_bindings, + static_animation_frame, + )?; + let should_render = tree.as_ref().map_or(true, |_| { + unit_component_is_visible(component_index, visual.source_type) + }); + // 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. + // Do not ask the MSH hierarchy to resolve hidden internal parts. + let mount_pose = if should_render { + if let Some(tree) = tree.as_ref() { + let record = records.get(component_index).ok_or_else(|| { + format!("unit component {component_index} is outside decoded records") + })?; + let parent_index = tree[component_index].parent_index; + match parent_index { + None => None, + Some(parent_index) => { + let parent_visible = component_refs.get(parent_index).is_some_and( + |(parent_visual, _)| { + unit_component_is_visible( + parent_index, + parent_visual.source_type, + ) + }, + ); + if !parent_visible { + None + } else { + let parent_model = component_refs + .get(parent_index) + .and_then(|(_, model)| *model) + .ok_or_else(|| { + format!( + "unit component {component_index} parent {parent_index} has no model for mount socket" + ) + })?; + let parent_hierarchy = component_pose_buffers + .get(parent_index) + .and_then(Option::as_ref); + let parent_is_mounted = + component_mount_poses[parent_index].is_some(); + let socket = + usize::try_from(record.parent_or_link).map_err(|_| { + format!( + "unit component {component_index} has negative mount socket {}", + record.parent_or_link + ) + })?; + let parent_socket = match parent_hierarchy { + Some(hierarchy) if parent_is_mounted => { + node38_pose_relative_to_root_from_hierarchy( + hierarchy, socket, + ) + } + Some(hierarchy) => { + node38_pose_from_hierarchy(hierarchy, socket) + } + None if parent_is_mounted => node38_pose_relative_to_root( + &parent_model.validated, + static_animation_frame, + socket, + ), + None => node38_pose( + &parent_model.validated, + static_animation_frame, + socket, + ), + } + .ok_or_else(|| { + format!( + "unit component {component_index} mount socket {socket} is absent from parent {parent_index} (parent source={:?}, node_count={}, slots={}, animation={})", + parent_model.source, + parent_model.validated.node_count, + parent_model.validated.slots.len(), + parent_model.validated.animation.is_some(), + ) + })?; + let parent_root_pose = component_mount_poses[parent_index] + .unwrap_or(VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }); + Some(compose_unit_pose(parent_root_pose, parent_socket)) + } + } + } } else { - project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( - &model.validated, - transform, - root.scale, - ) + None } } else { - let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; - if let Some(animation_frame) = static_animation_frame { - project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses( - &model.validated, - frame, - transform, - root.scale, - animation_frame, - ) - } else { - project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses( - &model.validated, - frame, - transform, - root.scale, - ) - } + None + }; + if let Some(mount_pose) = mount_pose { + component_mount_poses[component_index] = Some(mount_pose); + } + + let transform = LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }; + if should_render { + let pose_buffer = component_pose_buffers[component_index].as_ref(); + let component = match (pose_buffer, mount_pose, static_animation_frame) { + (Some(poses), Some(mount), _) => { + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &model.validated, + transform, + mission_root.scale, + poses, + mount, + ) + } + (Some(poses), None, _) => { + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &model.validated, + transform, + mission_root.scale, + poses, + ) + } + (None, Some(mount), Some(frame)) => { + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount( + &model.validated, + transform, + mission_root.scale, + frame, + mount, + ) + } + (None, Some(mount), None) => { + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount( + &model.validated, + transform, + mission_root.scale, + mount, + ) + } + (None, None, Some(frame)) => { + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( + &model.validated, + transform, + mission_root.scale, + frame, + ) + } + (None, None, None) => { + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( + &model.validated, + transform, + mission_root.scale, + ) + } + } + .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; + let component_shadow_triangles = static_mesh_shadow_triangles(&component); + if root_native_kind.is_some() { + root_shadow_triangles.extend(component_shadow_triangles); + let node1_pose = mount_pose.and_then(|_| match pose_buffer { + Some(poses) => node38_pose_relative_to_root_from_hierarchy(poses, 1), + None => node38_pose_relative_to_root( + &model.validated, + static_animation_frame, + 1, + ), + }); + if let Some(sphere) = mounted_shadow_sphere( + model.validated.bounding_sphere_center, + model.validated.bounding_sphere_radius, + mount_pose, + node1_pose, + ) { + root_shadow_spheres.push(sphere); + } + } + let selector_remap = static_preview_component_materials( + assets, + visual, + &component, + static_material_phase, + &mut materials, + &mut material_animations, + )?; + let lightmap_textures = static_preview_lightmap_textures(assets, visual)?; + let range_start = mesh.draw_ranges.len(); + append_static_preview_component_with_lightmaps( + &mut mesh, + component, + &selector_remap, + Some(&mut materials), + &lightmap_textures, + Some(&mut material_animations), + )?; + if root_sun_eligible { + sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); + } + mesh_components += 1; + } + } + if let Some(native_kind) = root_native_kind { + let caster_id = u32::try_from(shadow_casters.len()) + .map_err(|_| "shadow caster id exceeds u32".to_string())?; + if let Some(caster) = aggregate_shadow_caster( + caster_id, + native_kind, + LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }, + mission_root.scale, + root_shadow_triangles, + &root_shadow_spheres, + ) { + shadow_casters.push(caster); } - .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; - let selector_remap = static_preview_component_materials( - assets, - visual, - &component, - static_material_phase, - &mut materials, - )?; - append_static_preview_component(&mut mesh, component, &selector_remap)?; - mesh_components += 1; } } if mesh_components == 0 { return Err("selected static preview roots have no mesh-backed visual".to_string()); } - let camera = legacy_camera.unwrap_or_default(); + let free_camera = if legacy_camera.is_none() { + Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?) + } else { + None + }; + let camera = legacy_camera.unwrap_or_else(|| { + free_camera + .map(|camera| camera.vulkan_camera(16.0 / 9.0)) + .unwrap_or_default() + }); + let shadow_material_index = append_shadow_material(&mut materials)?; Ok(StaticPreviewScene { mesh, materials, + material_animations, + sun_occlusion_range_indices, + shadow_casters, + shadow_receivers, + shadow_material_index, + terrain: Arc::new(terrain.clone()), camera, + free_camera, camera_mode: if legacy_camera.is_some() { "legacy-d3d7-capture" } else { - "diagnostic-xy" + "free-flight" }, mesh_components, terrain_components: 1, }) } -fn static_preview_xy_frame( - assets: &MissionAssets, - terrain: &TerrainWorld, - roots: &[MissionObjectDraft], -) -> Result { - let mut min_x = f32::INFINITY; - let mut max_x = f32::NEG_INFINITY; - let mut min_y = f32::INFINITY; - let mut max_y = f32::NEG_INFINITY; - let terrain_positions = terrain - .source_positions() - .ok_or_else(|| "runtime terrain does not retain source positions".to_string())?; - for position in terrain_positions { - extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?; - } - for (object_index, root) in roots.iter().enumerate() { - if !root - .position - .iter() - .chain(root.scale.iter()) - .all(|value| value.is_finite()) - { - return Err(format!( - "static preview root {object_index} has a non-finite position or scale" - )); - } - for visual_id in assets.visuals_for_object(object_index) { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!( - "static preview root {object_index} references unknown visual {visual_id:?}" - ) - })?; - let Some(model_id) = visual.model_id else { - continue; - }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - for position in &model.validated.positions { - let position = LegacyIron3dEulerTransform { - translation: root.position, - orientation_radians: root.orientation_raw, - } - .try_transform_scaled_point(*position, root.scale) - .ok_or_else(|| { - "static preview contains a non-finite transformed XY position".to_string() - })?; - extend_static_preview_xy_bounds( - position, &mut min_x, &mut max_x, &mut min_y, &mut max_y, - )?; - } - } - } - VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y) - .map_err(|err| format!("build static preview XY frame: {err}")) -} - -fn extend_static_preview_xy_bounds( - position: [f32; 3], - min_x: &mut f32, - max_x: &mut f32, - min_y: &mut f32, - max_y: &mut f32, -) -> Result<(), String> { - if !position.iter().all(|value| value.is_finite()) { - return Err("static preview contains a non-finite XY position".to_string()); - } - *min_x = min_x.min(position[0]); - *max_x = max_x.max(position[0]); - *min_y = min_y.min(position[1]); - *max_y = max_y.max(position[1]); - Ok(()) -} - fn static_preview_terrain_base_materials( + loader: &mut StandaloneWearMaterialLoader, root: &std::path::Path, land_msh_path: &str, mesh: &VulkanStaticMesh, materials: &mut Vec, + material_animations: &mut Vec, + static_material_phase: Option, ) -> Result, String> { let land_path = root.join(land_msh_path); - let wear_path = land_path + let land_parent = land_path .parent() - .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))? - .join("Land2.wea"); + .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?; + let land1_path = land_parent.join("Land1.wea"); + let land2_path = land_parent.join("Land2.wea"); let mut packed_tags = mesh .draw_ranges .iter() @@ -342,29 +3905,231 @@ fn static_preview_terrain_base_materials( packed_tags .into_iter() .map(|packed_tag| { - let selection = TerrainMaterialLayers::from_packed_tag(packed_tag).land2_selection(); - let image = load_standalone_wear_material_texture_mip0_rgba8_from_root( - root, - &wear_path, - selection.material_index, - ) - .map_err(|err| { - format!( - "resolve terrain base material tag 0x{packed_tag:04x} through {}: {err}", - wear_path.display() - ) - })?; - let preview_selector = u16::try_from(materials.len()).map_err(|_| { - "static preview exceeds the available 16-bit material selector space".to_string() - })?; - materials.push(VulkanStaticMaterial { - material_index: preview_selector, - texture: VulkanStaticTexture { - width: image.width, - height: image.height, - rgba8: image.rgba8, - }, + let layers = TerrainMaterialLayers::from_packed_tag(packed_tag); + let base_document = loader + .material_document(&land1_path, layers.base_selection().material_index) + .map_err(|err| format!("resolve terrain base MAT0 document: {err}"))?; + let detail_document = loader + .material_document(&land2_path, layers.detail_base_selection().material_index) + .map_err(|err| format!("resolve terrain detail MAT0 document: {err}"))?; + let overlay_selection = layers.overlay_selection(); + let overlay_detail_selection = layers.detail_overlay_selection(); + let overlay_document = overlay_selection + .map(|selection| loader.material_document(&land1_path, selection.material_index)) + .transpose() + .map_err(|err| format!("resolve terrain overlay MAT0 document: {err}"))?; + let overlay_detail_document = overlay_detail_selection + .map(|selection| loader.material_document(&land2_path, selection.material_index)) + .transpose() + .map_err(|err| format!("resolve terrain overlay detail MAT0 document: {err}"))?; + let base_phases = load_standalone_phase_set( + loader, + &land1_path, + layers.base_selection().material_index, + &base_document, + )?; + let detail_phases = load_standalone_phase_set( + loader, + &land2_path, + layers.detail_base_selection().material_index, + &detail_document, + )?; + let overlay_phases = overlay_selection + .map(|selection| { + load_standalone_phase_set( + loader, + &land1_path, + selection.material_index, + overlay_document.as_ref().expect("overlay document"), + ) + }) + .transpose()?; + let overlay_detail_phases = overlay_detail_selection + .map(|selection| { + load_standalone_phase_set( + loader, + &land2_path, + selection.material_index, + overlay_detail_document + .as_ref() + .expect("overlay detail document"), + ) + }) + .transpose()?; + let phase_count = base_phases.len(); + if phase_count == 0 || detail_phases.is_empty() { + return Err(format!( + "terrain material tag 0x{packed_tag:04x} has incomplete phase tables" + )); + } + if overlay_phases.as_ref().is_some_and(Vec::is_empty) + || overlay_detail_phases.as_ref().is_some_and(Vec::is_empty) + { + return Err(format!( + "terrain material tag 0x{packed_tag:04x} has an empty optional phase table" + )); + } + 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_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 mut phase_material_indices = Vec::with_capacity(phase_count); + for phase_index in 0..phase_count { + let (base, base_phase) = &base_phases[phase_index]; + // The base draw selector follows the base MAT0 timeline. The + // other descriptor stages start at their own initial phase; + // frame-time sampling below can then advance them separately. + let (detail, detail_phase) = &detail_phases[initial_detail_phase_index]; + let overlay = overlay_phases + .as_ref() + .zip(initial_overlay_phase_index) + .map(|(phases, phase_index)| &phases[phase_index]); + let overlay_detail = overlay_detail_phases + .as_ref() + .zip(initial_overlay_detail_phase_index) + .map(|(phases, phase_index)| &phases[phase_index]); + let preview_selector = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index: preview_selector, + texture: base.clone(), + detail_texture: Some(detail.clone()), + overlay_texture: overlay.map(|(texture, _)| texture.clone()), + overlay_detail_texture: overlay_detail.map(|(texture, _)| texture.clone()), + diffuse_alpha: base_phase.diffuse_alpha, + overlay_diffuse_alpha: overlay.map(|(_, phase)| phase.diffuse_alpha), + directional_rgb: base_phase.directional_rgb, + additive_rgb: base_phase.additive_rgb, + uv_transform: base_phase.page_uv_transform, + overlay_directional_rgb: overlay.map(|(_, phase)| phase.directional_rgb), + overlay_additive_rgb: overlay.map(|(_, phase)| phase.additive_rgb), + specular_rgb: base_phase.specular_rgb, + specular_power: base_phase.power, + overlay_specular_rgb: overlay.map(|(_, phase)| phase.specular_rgb), + overlay_specular_power: overlay.map(|(_, phase)| phase.power), + detail_uv_transform: detail_phase.page_uv_transform, + overlay_uv_transform: overlay.map(|(_, phase)| phase.page_uv_transform), + overlay_detail_uv_transform: overlay_detail + .map(|(_, phase)| phase.page_uv_transform), + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + phase_material_indices.push(usize::from(preview_selector)); + } + let base_uvs = phase_uv_transforms_from_standalone( + loader, + &land1_path, + layers.base_selection().material_index, + &base_document, + )?; + let detail_uvs = phase_uv_transforms_from_standalone( + loader, + &land2_path, + layers.detail_base_selection().material_index, + &detail_document, + )?; + let overlay_uvs = match (&overlay_document, layers.overlay_selection()) { + (Some(document), Some(selection)) => phase_uv_transforms_from_standalone( + loader, + &land1_path, + selection.material_index, + document, + )?, + _ => Vec::new(), + }; + let overlay_detail_uvs = + match (&overlay_detail_document, layers.detail_overlay_selection()) { + (Some(document), Some(selection)) => phase_uv_transforms_from_standalone( + loader, + &land2_path, + selection.material_index, + document, + )?, + _ => Vec::new(), + }; + let stage_textures = MaterialStageTextures { + detail: Some( + detail_phases + .iter() + .map(|(texture, _)| texture.clone()) + .collect(), + ), + overlay: overlay_phases + .as_ref() + .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()), + overlay_detail: overlay_detail_phases + .as_ref() + .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()), + applied_material_phases: phase_material_indices + .iter() + .copied() + .map(|material_index| { + ( + material_index, + [ + initial_detail_phase_index, + initial_overlay_phase_index.unwrap_or(0), + initial_overlay_detail_phase_index.unwrap_or(0), + ], + ) + }) + .collect(), + }; + material_animations.push(MaterialAnimationBinding { + initial_phase_index: static_material_phase + .map_or(0, usize::from) + .min(base_document.phases.len().saturating_sub(1)), + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: base_document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: base_uvs, + }, + detail: Some(MaterialPhaseBinding { + document: detail_document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0x5A5A_A5A5, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: detail_uvs, + }), + overlay: overlay_document.map(|document| MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0xA5A5_5A5A, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: overlay_uvs, + }), + overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0xC3C3_3C3C, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: overlay_detail_uvs, + }), + stage_textures: Some(stage_textures), + }); + let preview_selector = + u16::try_from(materials.len() - phase_count + initial_phase_index) + .map_err(|_| "terrain material selector exceeds u16".to_string())?; Ok((packed_tag, preview_selector)) }) .collect() @@ -376,6 +4141,7 @@ fn static_preview_component_materials( mesh: &VulkanStaticMesh, static_material_phase: Option, materials: &mut Vec, + material_animations: &mut Vec, ) -> Result, String> { let mut source_selectors = mesh .draw_ranges @@ -398,44 +4164,143 @@ fn static_preview_component_materials( let material = assets.material_by_id(*material_id).ok_or_else(|| { format!("static preview prepared material {source_selector} is unavailable") })?; - let texture_name = static_material_phase - .and_then(|phase_index| { - let phase = material.mat0.phases.get(usize::from(phase_index))?; - let length = phase.texture_raw.iter().position(|byte| *byte == 0).unwrap_or(phase.texture_raw.len()); - (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec())) - }) - .or_else(|| material.texture_requests.first().cloned()) - .ok_or_else(|| { - format!("static preview material {source_selector} has no MAT0 diffuse texture") + let initial_phase_index = usize::from(static_material_phase.unwrap_or(0)) + .min(material.mat0.phases.len().saturating_sub(1)); + if material.mat0.phases.is_empty() { + return Err(format!( + "static preview material {source_selector} has no MAT0 phases" + )); + } + let mut phase_material_indices = Vec::with_capacity(material.mat0.phases.len()); + for phase in &material.mat0.phases { + let texture_name = phase_texture_name(phase); + let prepared_texture = texture_name.as_ref().and_then(|name| { + assets.textures.iter().find(|texture| { + texture.usage == PreparedTextureUsage::Diffuse + && texture.source.name == *name + }) + }); + // Empty MAT0 texture names remain valid untextured materials. + let texture = prepared_texture + .map(|texture| { + prepared_texture_to_vulkan(texture, "static preview diffuse texture") + }) + .transpose()? + .unwrap_or_else(|| solid_environment_texture([255, 255, 255, 255])); + let coefficients = phase.coefficients(); + let uv_transform = if let Some(texture) = prepared_texture { + page_uv_transform( + texture.texm.width(), + texture.texm.height(), + &texture.texm.page_rects(), + coefficients.page_index, + )? + } else { + [0.0, 0.0, 1.0, 1.0] + }; + let preview_selector = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() })?; + materials.push(VulkanStaticMaterial { + material_index: preview_selector, + texture, + detail_texture: None, + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: coefficients.opacity, + overlay_diffuse_alpha: None, + directional_rgb: coefficients.directional_rgb, + additive_rgb: coefficients.additive_rgb, + uv_transform, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: coefficients.specular_rgb, + specular_power: coefficients.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: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + phase_material_indices.push(usize::from(preview_selector)); + } + let preview_selector = u16::try_from( + *phase_material_indices + .get(initial_phase_index) + .ok_or_else(|| "MAT0 phase variant table is empty".to_string())?, + ) + .map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?; + material_animations.push(MaterialAnimationBinding { + initial_phase_index, + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: material.mat0.clone(), + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: material_id.raw() as u32, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: phase_uv_transforms_from_assets(&material.mat0, assets), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + Ok((source_selector, preview_selector)) + }) + .collect() +} + +fn prepared_texture_to_vulkan( + texture: &PreparedTexture, + label: &str, +) -> Result { + let mip_images = (0..texture.texm.mip_count()) + .map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX))) + .collect::, _>>() + .map_err(|err| format!("decode {label} {:?}: {err}", texture.source.name))?; + let image = mip_images + .first() + .ok_or_else(|| format!("{label} {:?} has no mip zero", texture.source.name))?; + Ok(VulkanStaticTexture { + width: image.width, + height: image.height, + rgba8: image.rgba8.clone(), + mip_levels: mip_images + .into_iter() + .map(|image| VulkanStaticTextureMip { + width: image.width, + height: image.height, + rgba8: image.rgba8, + }) + .collect(), + }) +} + +fn static_preview_lightmap_textures( + assets: &MissionAssets, + visual: &PreparedVisual, +) -> Result, String> { + visual + .lightmap_ids + .iter() + .map(|id| { let texture = assets .textures .iter() .find(|texture| { - texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == texture_name + texture.id == *id && texture.usage == PreparedTextureUsage::Lightmap }) - .ok_or_else(|| { - format!( - "static preview diffuse texture {texture_name:?} for material {source_selector} is unavailable" - ) - })?; - let image = texture.decode_mip_rgba8(0).map_err(|err| { - format!( - "decode static preview diffuse texture {texture_name:?} for material {source_selector}: {err}" - ) - })?; - let preview_selector = u16::try_from(materials.len()).map_err(|_| { - "static preview exceeds the available 16-bit material selector space".to_string() - })?; - materials.push(VulkanStaticMaterial { - material_index: preview_selector, - texture: VulkanStaticTexture { - width: image.width, - height: image.height, - rgba8: image.rgba8, - }, - }); - Ok((source_selector, preview_selector)) + .ok_or_else(|| format!("static preview lightmap {id:?} is unavailable"))?; + prepared_texture_to_vulkan(texture, "static preview lightmap") }) .collect() } @@ -444,6 +4309,25 @@ fn append_static_preview_component( target: &mut VulkanStaticMesh, component: VulkanStaticMesh, selector_remap: &[(u16, u16)], + material_animations: Option<&mut Vec>, +) -> Result<(), String> { + append_static_preview_component_with_lightmaps( + target, + component, + selector_remap, + None, + &[], + material_animations, + ) +} + +fn append_static_preview_component_with_lightmaps( + target: &mut VulkanStaticMesh, + component: VulkanStaticMesh, + selector_remap: &[(u16, u16)], + mut materials: Option<&mut Vec>, + lightmap_textures: &[VulkanStaticTexture], + mut material_animations: Option<&mut Vec>, ) -> Result<(), String> { let vertex_base = u32::try_from(target.vertices.len()) .map_err(|_| "static preview vertex count exceeds u32".to_string())?; @@ -466,8 +4350,14 @@ fn append_static_preview_component( }) .collect::, _>>()?, ); + // A lightmapped range must keep the lightmap material family when MAT0 + // 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 update_material_animations can rebind every frame. + let mut lightmap_materials: HashMap<(usize, u8), (Vec, usize, usize)> = HashMap::new(); for range in component.draw_ranges { - let material_index = selector_remap + let source_material_index = selector_remap .iter() .find_map(|(source, preview)| (*source == range.material_index).then_some(*preview)) .ok_or_else(|| { @@ -476,6 +4366,95 @@ fn append_static_preview_component( range.material_index ) })?; + let (material_index, lightmap_variant) = if lightmap_textures + .get(usize::from(range.lightmap_index)) + .is_some() + { + let key = (usize::from(source_material_index), range.lightmap_index); + let (phase_material_indices, binding_index, variant_index) = if let Some(existing) = + lightmap_materials.get(&key) + { + existing.clone() + } else { + let bindings = material_animations.as_deref().ok_or_else(|| { + "static preview lightmap animation storage is unavailable".to_string() + })?; + let (binding_index, source_phase_indices) = + find_material_animation_variant(bindings, usize::from(source_material_index)) + .ok_or_else(|| { + format!( + "static preview material selector {source_material_index} has no MAT0 animation binding" + ) + })?; + let lightmap = lightmap_textures + .get(usize::from(range.lightmap_index)) + .expect("lightmap presence checked above"); + let materials = materials.as_mut().ok_or_else(|| { + "static preview lightmap material storage is unavailable".to_string() + })?; + let mut phase_material_indices = Vec::with_capacity(source_phase_indices.len()); + for source_phase_index in source_phase_indices { + let mut lightmap_material = materials + .get(source_phase_index) + .cloned() + .ok_or_else(|| { + format!( + "static preview material selector {source_phase_index} is unavailable" + ) + })?; + let original_directional = lightmap_material.directional_rgb; + lightmap_material.directional_rgb = [0.0; 3]; + lightmap_material.additive_rgb = original_directional; + lightmap_material.detail_texture = Some(lightmap.clone()); + lightmap_material.detail_uv_transform = [0.0, 0.0, 1.0, 1.0]; + lightmap_material.overlay_texture = None; + lightmap_material.overlay_detail_texture = None; + lightmap_material.overlay_diffuse_alpha = None; + lightmap_material.overlay_directional_rgb = None; + lightmap_material.overlay_additive_rgb = None; + lightmap_material.overlay_uv_transform = None; + lightmap_material.overlay_detail_uv_transform = None; + lightmap_material.unlit = false; + lightmap_material.sky_nebula_stars = false; + lightmap_material.lightmap_mode = true; + let index = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() + })?; + lightmap_material.material_index = index; + materials.push(lightmap_material); + phase_material_indices.push(usize::from(index)); + } + let bindings = material_animations.as_deref_mut().ok_or_else(|| { + "static preview lightmap animation storage is unavailable".to_string() + })?; + let variant_index = bindings[binding_index].variants.len(); + bindings[binding_index].variants.push(MaterialVariantSet { + phase_material_indices: phase_material_indices.clone(), + range_indices: Vec::new(), + }); + let value = (phase_material_indices, binding_index, variant_index); + lightmap_materials.insert(key, value.clone()); + value + }; + let initial_material = *phase_material_indices + .get( + material_animations + .as_deref() + .and_then(|bindings| bindings.get(binding_index)) + .map_or(0, |binding| binding.initial_phase_index), + ) + .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?; + ( + u16::try_from(initial_material).map_err(|_| { + "static preview lightmap material selector exceeds u16".to_string() + })?, + Some((binding_index, variant_index)), + ) + } else { + (source_material_index, None) + }; + let target_range_index = target.draw_ranges.len(); target .draw_ranges .push(fparkan_render_vulkan::VulkanStaticDrawRange { @@ -485,27 +4464,146 @@ fn append_static_preview_component( material_index, ..range }); + if let Some(bindings) = material_animations.as_deref_mut() { + if let Some((binding_index, variant_index)) = lightmap_variant { + bindings + .get_mut(binding_index) + .and_then(|binding| binding.variants.get_mut(variant_index)) + .ok_or_else(|| { + "static preview lightmap animation variant is missing".to_string() + })? + .range_indices + .push(target_range_index); + } else { + register_material_range( + bindings, + usize::from(source_material_index), + target_range_index, + ); + } + } } Ok(()) } +fn load_standalone_vulkan_texture( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + phase_index: u16, +) -> Result<(VulkanStaticTexture, PreparedMaterialPhase), String> { + let (mips, phase) = loader.load(wear_path, material_index, phase_index)?; + let first = mips + .first() + .ok_or_else(|| "standalone TEXM mip loader returned no mip zero".to_string())?; + Ok(( + VulkanStaticTexture { + width: first.width, + height: first.height, + rgba8: first.rgba8.clone(), + mip_levels: mips + .into_iter() + .map(|image| VulkanStaticTextureMip { + width: image.width, + height: image.height, + rgba8: image.rgba8, + }) + .collect(), + }, + phase, + )) +} + +fn load_standalone_phase_set( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + document: &Mat0Document, +) -> Result, String> { + (0..document.phases.len()) + .map(|phase_index| { + load_standalone_vulkan_texture( + loader, + wear_path, + material_index, + u16::try_from(phase_index) + .map_err(|_| "MAT0 phase index exceeds u16".to_string())?, + ) + }) + .collect() +} + fn run_static_vulkan_mode( preview: StaticPreviewScene, + environment: Option, + audio: Option, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, enable_validation: bool, + startup_timings: StartupTimings, ) -> Result { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); + let mut preview = preview; + let mut environment = environment; + // Reserve the projected-shadow draw range before the environment builder + // reorders its fixed passes. The range itself remains a stable draw slot; + // frame geometry is appended after every static environment allocation. + let mut shadow_range = append_environment_range( + &mut preview.mesh, + preview.shadow_material_index, + shadow_placeholder_vertices(), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + let environment_gpu = match environment.as_mut() { + Some(environment) => { + let camera = preview.free_camera.unwrap_or_else(|| { + FreeFlightCamera::from_mesh(&preview.mesh).unwrap_or(FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_3, + near_plane: 0.1, + far_plane: 100_000.0, + move_speed: 1.0, + }) + }); + let environment_materials = environment.materials.clone(); + let material_assets = environment.material_assets.clone(); + let sky_mesh = environment.sky.mesh().clone(); + Some(EnvironmentGpuScene::new( + &mut preview.mesh, + &mut preview.materials, + &mut preview.material_animations, + &mut preview.sun_occlusion_range_indices, + &environment_materials, + &material_assets, + &sky_mesh, + &camera, + &mut shadow_range.range_index, + )?) + } + None => None, + }; + let shadow_base_vertex = preview.mesh.vertices.len(); + let shadow_base_index = preview.mesh.indices.len(); let mut app = StaticVulkanApp::new( preview, + environment, + environment_gpu, + shadow_range, + shadow_base_vertex, + shadow_base_index, + audio, target_frames, mission, object_count, readback_out.map(std::path::Path::to_path_buf), enable_validation, + startup_timings, ); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); @@ -516,7 +4614,32 @@ fn run_static_vulkan_mode( struct StaticVulkanApp { mesh: VulkanStaticMesh, materials: Vec, + material_animations: Vec, + sun_occlusion_range_indices: Vec, + terrain: Arc, + world_range_indices: Vec, + world_range_index_counts: HashMap, camera: VulkanStaticCamera, + free_camera: Option, + environment: Option, + environment_gpu: Option, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + shadow_material_index: usize, + shadow_casters: Vec, + shadow_receivers: Vec, + shadow_cache: ShadowPageCache, + shadow_last_evidence: ShadowFrameEvidence, + environment_last_tick: Instant, + material_last_tick: Instant, + material_elapsed: Duration, + material_time_ms: u32, + environment_primitive_count: usize, + pressed_keys: HashSet, + last_tick: Instant, + mouse_look: bool, + audio: Option, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, @@ -525,6 +4648,7 @@ struct StaticVulkanApp { object_count: usize, readback_out: Option, enable_validation: bool, + startup_timings: StartupTimings, window_id: Option, window: Option, renderer: Option, @@ -536,16 +4660,64 @@ struct StaticVulkanApp { impl StaticVulkanApp { fn new( preview: StaticPreviewScene, + environment: Option, + environment_gpu: Option, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + audio: Option, target_frames: u64, mission: &str, object_count: usize, readback_out: Option, enable_validation: bool, + startup_timings: StartupTimings, ) -> Self { + let mut fixed_range_indices = environment_gpu + .as_ref() + .map(EnvironmentGpuScene::fixed_range_indices) + .unwrap_or_default(); + 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_index_counts = preview + .mesh + .draw_ranges + .iter() + .enumerate() + .filter(|(range_index, _)| !fixed_range_indices.contains(range_index)) + .map(|(range_index, range)| (range_index, range.index_count)) + .collect(); Self { mesh: preview.mesh, materials: preview.materials, + material_animations: preview.material_animations, + sun_occlusion_range_indices: preview.sun_occlusion_range_indices, + terrain: preview.terrain, + world_range_indices, + world_range_index_counts, camera: preview.camera, + free_camera: preview.free_camera, + environment, + environment_gpu, + shadow_base_vertex, + shadow_base_index, + shadow_material_index: preview.shadow_material_index, + shadow_range, + shadow_casters: preview.shadow_casters, + shadow_receivers: preview.shadow_receivers, + shadow_cache: ShadowPageCache::default(), + shadow_last_evidence: ShadowFrameEvidence::default(), + environment_last_tick: Instant::now(), + material_last_tick: Instant::now(), + material_elapsed: Duration::ZERO, + material_time_ms: 0, + environment_primitive_count: 0, + pressed_keys: HashSet::new(), + last_tick: Instant::now(), + mouse_look: false, + audio, camera_mode: preview.camera_mode, mesh_components: preview.mesh_components, terrain_components: preview.terrain_components, @@ -554,6 +4726,7 @@ impl StaticVulkanApp { object_count, readback_out, enable_validation, + startup_timings, window_id: None, window: None, renderer: None, @@ -563,6 +4736,313 @@ impl StaticVulkanApp { } } + fn configure_world_draw_ranges( + &self, + renderer: &mut VulkanSmokeRenderer, + ) -> Result<(), String> { + for &range_index in &self.world_range_indices { + let range = self + .mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let alpha = self + .materials + .get(usize::from(range.material_index)) + .map_or(1.0, |material| material.diffuse_alpha); + let transparent = native_world_transparent(range, alpha); + renderer + .set_draw_range_transparency(range_index, transparent) + .map_err(|error| format!("configure world draw range {range_index}: {error}"))?; + renderer + .set_draw_range_index_count( + range_index, + if alpha > 0.0 { range.index_count } else { 0 }, + ) + .map_err(|error| { + format!("configure world draw range {range_index} count: {error}") + })?; + } + Ok(()) + } + + fn update_world_transparency( + mesh: &VulkanStaticMesh, + materials: &[VulkanStaticMaterial], + world_range_indices: &[usize], + world_range_index_counts: &HashMap, + active_materials: &HashMap, + renderer: &mut VulkanSmokeRenderer, + ) -> Result<(), String> { + for &range_index in world_range_indices { + let range = mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let state = active_materials.get(&range_index); + let alpha = state.map_or_else( + || { + materials + .get(usize::from(range.material_index)) + .map_or(1.0, |material| material.diffuse_alpha) + }, + |state| state.diffuse_alpha, + ); + renderer + .set_draw_range_transparency(range_index, native_world_transparent(range, alpha)) + .map_err(|error| format!("update world draw range {range_index}: {error}"))?; + let count = if alpha > 0.0 { + *world_range_index_counts + .get(&range_index) + .ok_or_else(|| "world draw range count is missing".to_string())? + } else { + 0 + }; + renderer + .set_draw_range_index_count(range_index, count) + .map_err(|error| format!("update world draw range {range_index} count: {error}"))?; + } + Ok(()) + } + + fn world_draw_sort_keys(&self, camera_position: [f32; 3]) -> Result, String> { + let mut keys = Vec::with_capacity(self.world_range_indices.len()); + for &range_index in &self.world_range_indices { + let sort_key = first_indexed_vertex_distance(&self.mesh, range_index, camera_position)?; + keys.push((range_index, sort_key)); + } + Ok(keys) + } + + fn update_free_camera(&mut self) -> Result<(), String> { + if self.free_camera.is_none() { + return Ok(()); + } + let now = Instant::now(); + let elapsed = now + .saturating_duration_since(self.last_tick) + .min(Duration::from_millis(100)); + self.last_tick = now; + let Some(window) = self.window.as_ref() else { + return Ok(()); + }; + let size = window.inner_size(); + let aspect = if size.height == 0 { + 16.0 / 9.0 + } else { + size.width as f32 / size.height as f32 + }; + let (camera, listener_position, listener_forward, listener_up) = { + let free_camera = self + .free_camera + .as_mut() + .ok_or_else(|| "free-flight camera disappeared".to_string())?; + free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32()); + ( + free_camera.vulkan_camera(aspect), + free_camera.position, + free_camera.forward(), + free_camera.up(), + ) + }; + self.camera = camera; + let sort_keys = self.world_draw_sort_keys(listener_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}") + })?; + } + } + if let Some(audio) = self.audio.as_mut() { + audio + .update_listener(listener_position, listener_forward, listener_up) + .map_err(|error| format!("update audio listener: {error}"))?; + } + Ok(()) + } + + fn update_material_clock(&mut self) { + let now = Instant::now(); + let elapsed = now + .saturating_duration_since(self.material_last_tick) + .min(Duration::from_millis(100)); + self.material_last_tick = now; + self.material_elapsed = self.material_elapsed.saturating_add(elapsed); + // Keep the native u32 millisecond clock while flooring only the + // accumulated duration. This preserves sub-millisecond frame deltas + // instead of dropping them once per redraw. + self.material_time_ms = self.material_elapsed.as_millis() as u32; + } + + fn update_shadow( + mesh: &mut VulkanStaticMesh, + shadow_cache: &mut ShadowPageCache, + shadow_casters: &[ShadowCaster], + shadow_receivers: &[ShadowTriangle], + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + shadow_material_index: usize, + renderer: &mut VulkanSmokeRenderer, + sky_frame: Option<&SkyFrame<'_>>, + environment_frame: Option<&EnvironmentFrame>, + shadow_camera: Option, + ) -> Result { + let lights = shadow_lights(sky_frame, environment_frame, renderer); + let visible_casters = native_visible_shadow_casters( + shadow_casters, + renderer.frame_uniforms().clip_from_world, + ); + let frame = shadow_cache + .build(ShadowScene { + lights: &lights, + casters: &visible_casters, + receivers: shadow_receivers, + fade: 1.0, + smooth_passes: 1, + camera: shadow_camera, + lod: ShadowLodSettings::default(), + }) + .map_err(|error| format!("build projected shadow frame: {error}"))?; + let geometry = append_shadow_frame_geometry( + mesh, + shadow_range, + shadow_base_vertex, + shadow_base_index, + &frame, + )?; + let atlas = shadow_atlas_texture_from_frame(&frame); + renderer + .update_material_texture(shadow_material_index, 0, &atlas) + .map_err(|error| format!("upload projected shadow atlas: {error}"))?; + renderer + .update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry]) + .map_err(|error| format!("upload environment and shadow geometry: {error}"))?; + Ok(ShadowFrameEvidence { + visible_casters: visible_casters.len(), + projected_indices: frame.indices.len(), + }) + } + + fn update_environment(&mut self) -> Result<(), String> { + let Some(renderer) = self.renderer.as_mut() else { + return Ok(()); + }; + let viewport = self + .window + .as_ref() + .map(|window| { + let size = window.inner_size(); + [size.width.max(1) as f32, size.height.max(1) as f32] + }) + .unwrap_or([1280.0, 720.0]); + let aspect = viewport[0] / viewport[1].max(1.0); + let now = Instant::now(); + let dt = now + .saturating_duration_since(self.environment_last_tick) + .min(Duration::from_millis(100)) + .as_secs_f32(); + self.environment_last_tick = now; + let active_materials = update_material_animations( + &mut self.material_animations, + &self.materials, + renderer, + self.material_time_ms, + )?; + Self::update_world_transparency( + &self.mesh, + &self.materials, + &self.world_range_indices, + &self.world_range_index_counts, + &active_materials, + renderer, + )?; + let free_camera = self.free_camera; + if let (Some(camera), Some(environment)) = (free_camera, self.environment.as_mut()) { + let (sky_frame, sky_mesh, environment_frame) = + environment.update(dt, &camera, aspect, viewport, renderer, self.audio.as_mut())?; + self.environment_primitive_count = environment_frame.primitives.len(); + if let Some(environment_gpu) = self.environment_gpu.as_mut() { + environment_gpu.update( + &mut self.mesh, + &self.materials, + renderer, + &sky_frame, + &sky_mesh, + &environment_frame, + &camera, + viewport, + self.material_time_ms, + &self.terrain, + &self.sun_occlusion_range_indices, + )?; + } + let shadow_camera = ShadowCamera { + position: camera.position, + viewport_width: viewport[0], + horizontal_fov: horizontal_fov(&camera, aspect), + }; + self.shadow_last_evidence = Self::update_shadow( + &mut self.mesh, + &mut self.shadow_cache, + &self.shadow_casters, + &self.shadow_receivers, + self.shadow_range, + self.shadow_base_vertex, + self.shadow_base_index, + self.shadow_material_index, + renderer, + Some(&sky_frame), + Some(&environment_frame), + Some(shadow_camera), + )?; + } else { + let shadow_camera = free_camera.map(|camera| ShadowCamera { + position: camera.position, + viewport_width: viewport[0], + horizontal_fov: horizontal_fov(&camera, aspect), + }); + self.shadow_last_evidence = Self::update_shadow( + &mut self.mesh, + &mut self.shadow_cache, + &self.shadow_casters, + &self.shadow_receivers, + self.shadow_range, + self.shadow_base_vertex, + self.shadow_base_index, + self.shadow_material_index, + renderer, + None, + None, + shadow_camera, + )?; + } + Ok(()) + } + + fn set_mouse_look(&mut self, active: bool) { + self.mouse_look = active; + if let Some(window) = self.window.as_ref() { + if active { + // Relative device motion keeps the camera usable at the edge + // of the window. Locked grab is unavailable on a few window + // backends, so confined grab is a safe fallback. + if window.set_cursor_grab(CursorGrabMode::Locked).is_err() { + let _ = window.set_cursor_grab(CursorGrabMode::Confined); + } + } else { + let _ = window.set_cursor_grab(CursorGrabMode::None); + } + window.set_cursor_visible(!active); + } + } + fn schedule_next_redraw(&self) { if let Some(window) = self.window.as_ref() { window.request_redraw(); @@ -570,6 +5050,9 @@ impl StaticVulkanApp { } fn complete(&mut self, event_loop: &ActiveEventLoop) { + if let Some(audio) = self.audio.as_mut() { + audio.shutdown(); + } let Some(renderer) = self.renderer.take() else { self.error = Some("native Vulkan renderer was not initialized".to_string()); event_loop.exit(); @@ -588,8 +5071,10 @@ impl StaticVulkanApp { && (report.validation.warning_count != 0 || report.validation.error_count != 0) { self.error = Some(format!( - "native Vulkan validation must stay clean (warnings={}, errors={})", - report.validation.warning_count, report.validation.error_count + "native Vulkan validation must stay clean (warnings={}, errors={}, vuids={:?})", + report.validation.warning_count, + report.validation.error_count, + report.validation.vuids, )); event_loop.exit(); return; @@ -623,8 +5108,14 @@ impl StaticVulkanApp { } (None, _) => None, }; + let readback_format = report + .readback_artifact + .as_ref() + .map_or(report.renderer_report.swapchain_image_format, |artifact| { + artifact.format + }); self.output = Some(format!( - "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, validation={}, readback={}", + "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, 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}}}", self.mission, self.frames_presented, self.object_count, @@ -632,8 +5123,18 @@ impl StaticVulkanApp { self.terrain_components, self.camera_mode, self.materials.len(), + self.environment_primitive_count, + self.shadow_last_evidence.visible_casters, + self.shadow_last_evidence.projected_indices, + report.swapchain_recreate_count, validation_status, + report.renderer_report.swapchain_image_format, + readback_format, readback_path.as_deref().unwrap_or("none"), + elapsed_ms(self.startup_timings.mission_assets), + elapsed_ms(self.startup_timings.terrain_materials), + elapsed_ms(self.startup_timings.sky), + elapsed_ms(self.startup_timings.gpu_initialization), )); event_loop.exit(); } @@ -648,6 +5149,21 @@ impl StaticVulkanApp { } impl ApplicationHandler for StaticVulkanApp { + fn device_event( + &mut self, + _event_loop: &ActiveEventLoop, + _device_id: DeviceId, + event: DeviceEvent, + ) { + if self.mouse_look { + if let DeviceEvent::MouseMotion { delta } = event { + if let Some(camera) = self.free_camera.as_mut() { + camera.look_delta(delta.0, delta.1); + } + } + } + } + fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.window.is_some() { return; @@ -661,7 +5177,7 @@ impl ApplicationHandler for StaticVulkanApp { } }; let attributes = Window::default_attributes() - .with_title("FParkan static mission Vulkan") + .with_title("FParkan mission") .with_inner_size(WinitPhysicalSize::new(plan.width, plan.height)); let window = match event_loop.create_window(attributes) { Ok(window) => window, @@ -677,7 +5193,8 @@ impl ApplicationHandler for StaticVulkanApp { return; }; let size = window.inner_size(); - let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { + let gpu_initialization_started = Instant::now(); + let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { application_name: "fparkan-game".to_string(), native_handles, drawable_extent: (size.width.max(1), size.height.max(1)), @@ -695,9 +5212,47 @@ impl ApplicationHandler for StaticVulkanApp { return; } }; + if let Err(error) = self.configure_world_draw_ranges(&mut renderer) { + self.error = Some(error); + event_loop.exit(); + return; + } + if let Some(free_camera) = self.free_camera { + match self.world_draw_sort_keys(free_camera.position) { + Ok(sort_keys) => { + for (range_index, sort_key) in sort_keys { + if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key) + { + self.error = Some(format!( + "update world draw range {range_index} sort key: {error}" + )); + event_loop.exit(); + return; + } + } + } + Err(error) => { + self.error = Some(error); + event_loop.exit(); + return; + } + } + } + self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed(); + renderer.set_readback_enabled(self.readback_out.is_some()); self.window_id = Some(window.id()); self.renderer = Some(renderer); self.window = Some(window); + self.last_tick = Instant::now(); + self.environment_last_tick = Instant::now(); + self.material_last_tick = Instant::now(); + self.material_elapsed = Duration::ZERO; + self.material_time_ms = 0; + if let Err(error) = self.update_free_camera() { + self.error = Some(error); + event_loop.exit(); + return; + } self.schedule_next_redraw(); } @@ -714,12 +5269,53 @@ impl ApplicationHandler for StaticVulkanApp { WindowEvent::CloseRequested => { self.complete(event_loop); } + WindowEvent::Focused(focused) => { + if !focused { + self.pressed_keys.clear(); + self.set_mouse_look(false); + } + if let Some(audio) = self.audio.as_mut() { + audio.on_focus_changed(focused); + } + } + WindowEvent::KeyboardInput { event, .. } => { + if let PhysicalKey::Code(code) = event.physical_key { + if code == KeyCode::Escape && event.state == ElementState::Pressed { + if self.mouse_look { + self.set_mouse_look(false); + } else { + self.complete(event_loop); + } + } else if event.state == ElementState::Pressed { + self.pressed_keys.insert(code); + } else { + self.pressed_keys.remove(&code); + } + } + } + WindowEvent::MouseInput { state, button, .. } => { + if button == MouseButton::Right { + self.set_mouse_look(state == ElementState::Pressed); + } + } + WindowEvent::CursorMoved { .. } => {} WindowEvent::Resized(size) => { if let Some(renderer) = self.renderer.as_mut() { renderer.request_resize((size.width, size.height)); } } WindowEvent::RedrawRequested => { + if let Err(error) = self.update_free_camera() { + self.error = Some(error); + event_loop.exit(); + return; + } + self.update_material_clock(); + if let Err(error) = self.update_environment() { + self.error = Some(error); + event_loop.exit(); + return; + } let Some(renderer) = self.renderer.as_mut() else { self.error = Some("native Vulkan renderer was not initialized".to_string()); event_loop.exit(); @@ -738,7 +5334,7 @@ impl ApplicationHandler for StaticVulkanApp { return; } } - if self.frames_presented >= self.target_frames { + if self.target_frames != 0 && self.frames_presented >= self.target_frames { self.complete(event_loop); } else { self.schedule_next_redraw(); @@ -755,11 +5351,12 @@ impl ApplicationHandler for StaticVulkanApp { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] struct Args { root: PathBuf, mission: String, frames: u64, + atmosphere_seconds: Option, validation: bool, readback_out: Option, preview_roots: NonZeroUsize, @@ -772,7 +5369,11 @@ impl Args { fn parse(args: &[String]) -> Result { let mut root = None; let mut mission = None; - let mut frames = 1; + // Zero means keep the interactive preview open until the user closes + // it. A bounded `--frames N` remains available for smoke/readback + // runs and validation scripts. + let mut frames = 0; + let mut atmosphere_seconds = None; let mut validation = false; let mut readback_out = None; // A native static-Vulkan invocation is the usable mission preview, so @@ -807,6 +5408,19 @@ impl Args { .parse() .map_err(|_| "--frames must be an integer".to_string())?; } + "--atmosphere-seconds" => { + let value = iter + .next() + .ok_or_else(|| "--atmosphere-seconds requires a value".to_string())? + .parse::() + .map_err(|_| "--atmosphere-seconds must be a number".to_string())?; + if !value.is_finite() || value < 0.0 { + return Err( + "--atmosphere-seconds must be finite and non-negative".to_string() + ); + } + atmosphere_seconds = Some(value); + } "--validation" => { validation = true; } @@ -855,13 +5469,11 @@ impl Args { } let root = root.ok_or_else(|| "missing --root".to_string())?; let mission = mission.ok_or_else(|| "missing --mission".to_string())?; - if frames == 0 { - return Err("--frames must be greater than zero".to_string()); - } Ok(Self { root, mission, frames, + atmosphere_seconds, validation, readback_out, preview_roots, @@ -950,7 +5562,7 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result { } fn usage() -> String { - "usage: fparkan-game --root --mission [--frames ] [--validation] [--preview-roots ] [--legacy-camera-capture ] [--static-animation-frame ] [--static-material-phase ] [--readback-out ]".to_string() + "usage: fparkan-game --root --mission [--frames ] [--atmosphere-seconds ] [--validation] [--preview-roots ] [--legacy-camera-capture ] [--static-animation-frame ] [--static-material-phase ] [--readback-out ]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally.".to_string() } #[cfg(test)] @@ -961,6 +5573,314 @@ mod tests { values.iter().map(|value| (*value).to_string()).collect() } + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part1_prepares_every_mission() { + prepare_licensed_corpus("FPARKAN_CORPUS_PART1_ROOT", 29); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part2_prepares_every_mission() { + prepare_licensed_corpus("FPARKAN_CORPUS_PART2_ROOT", 31); + } + + fn prepare_licensed_corpus(variable: &str, expected_missions: usize) { + let root = std::env::var_os(variable) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("{variable} is required")); + assert!( + root.is_dir(), + "licensed corpus root is missing: {}", + root.display() + ); + + let mut missions = Vec::new(); + let mut directories = vec![root.clone()]; + while let Some(directory) = directories.pop() { + let entries = std::fs::read_dir(&directory) + .unwrap_or_else(|error| panic!("read {}: {error}", directory.display())); + for entry in entries { + let path = entry + .unwrap_or_else(|error| { + panic!("read entry in {}: {error}", directory.display()) + }) + .path(); + if path.is_dir() { + directories.push(path); + } else if path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("data.tma")) + { + let relative = path + .strip_prefix(&root) + .expect("mission path is under corpus root") + .to_str() + .expect("mission path is UTF-8") + .replace('\\', "/"); + missions.push(relative); + } + } + } + missions.sort(); + assert_eq!( + missions.len(), + expected_missions, + "installed mission count for {variable}" + ); + + let vfs: Arc = Arc::new(DirectoryVfs::new(&root)); + let mut prepared = 0usize; + let mut environment_missions = 0usize; + for mission in missions { + let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); + let loaded = load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: mission.clone(), + }, + NonZeroUsize::MAX, + ) + .unwrap_or_else(|error| panic!("prepare runtime mission {mission}: {error}")); + let assets = loaded_mission_assets(&engine).expect("mission assets"); + let terrain = loaded_terrain(&engine).expect("mission terrain"); + let roots = loaded_mission_object_drafts(&engine).expect("mission object drafts"); + let preview = static_preview_mesh_and_materials( + assets, + terrain, + roots, + None, + None, + None, + &root, + &loaded.land_msh_path, + ) + .unwrap_or_else(|error| panic!("prepare renderer mission {mission}: {error}")); + assert!(!preview.mesh.vertices.is_empty(), "{mission}: empty mesh"); + assert!( + !preview.materials.is_empty(), + "{mission}: empty material set" + ); + + if load_environment(&root, &mission, None) + .unwrap_or_else(|error| panic!("prepare environment mission {mission}: {error}")) + .is_some() + { + environment_missions += 1; + } + + // `GameAudio::new` resolves and decodes every sample declared by + // mission.cfg before opening the OS device. A headless CI host is + // therefore still a useful resource check: only the final device + // step may be unavailable. + match audio::GameAudio::new(vfs.clone(), &mission) { + Ok(mut audio) => audio.shutdown(), + Err(audio::AudioError::DeviceUnavailable { .. }) => {} + Err(error) => panic!("prepare audio mission {mission}: {error}"), + } + prepared += 1; + } + + assert_eq!(prepared, expected_missions); + assert_eq!(environment_missions, expected_missions); + } + + #[test] + fn draw_range_reorder_remaps_all_material_animation_bindings() { + let range = |first_index, material_index| VulkanStaticDrawRange { + first_index, + index_count: 3, + material_index, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: world_pipeline_state( + LegacyBlendMode::Opaque, + LegacyDepthMode::TestReadOnly, + ), + alpha_test_reference: 0, + }; + let mut mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: (0..9).collect(), + draw_ranges: vec![range(0, 10), range(3, 20), range(6, 30)], + }; + let empty_binding = || MaterialPhaseBinding { + document: Mat0Document { + version: 0, + animation_block_count: 0, + phases: Vec::new(), + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: Vec::new(), + }, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: Some(0), + phase_uv_transforms: Vec::new(), + }; + let mut bindings = vec![MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices: vec![10], + range_indices: vec![0, 2], + }], + base: empty_binding(), + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }]; + + let remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("reorder"); + remap_material_animation_ranges(&mut bindings, &remap).expect("binding remap"); + + assert_eq!( + mesh.indices, + (6..9).chain(0..3).chain(3..6).collect::>() + ); + assert_eq!(mesh.draw_ranges[0].material_index, 30); + assert_eq!(mesh.draw_ranges[1].material_index, 10); + assert_eq!(mesh.draw_ranges[2].material_index, 20); + assert_eq!(bindings[0].variants[0].range_indices, vec![1, 0]); + } + + #[test] + fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() { + let range = |blend, batch_flags| VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags, + pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly), + alpha_test_reference: 0, + }; + + // Transparency is driven by the native predicate. The pipeline blend + // enum is an independent rendering detail and does not qualify an + // opaque range on its own. + assert!(!native_world_transparent( + &range(LegacyBlendMode::SourceAlpha, 0), + 1.0 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0), + 0.999 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0x100), + 1.0 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0x8), + 1.0 + )); + } + + #[test] + fn native_sun_object_source_types_map_to_world_kinds() { + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3)); + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4)); + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10)); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT))); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU))); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT))); + assert!(!native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_EXTO))); + assert!(!native_sun_object_kind_eligible(None)); + } + + fn test_shadow_caster(id: u32, center: [f32; 3]) -> ShadowCaster { + ShadowCaster { + id, + world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + sphere: ShadowSphere { + center, + radius: 0.0, + }, + native_kind: 4, + triangles: Vec::new(), + } + } + + #[test] + fn shadow_frustum_filters_before_the_native_twenty_caster_budget() { + let casters = [ + test_shadow_caster(1, [202.0, 0.0, 0.0]), + test_shadow_caster(2, [0.0, 0.0, 0.0]), + ]; + let visible = + native_visible_shadow_casters(&casters, VulkanStaticCamera::default().clip_from_world); + assert_eq!( + visible.iter().map(|caster| caster.id).collect::>(), + [2] + ); + } + + #[test] + fn shadow_frustum_keeps_the_native_hundred_unit_sphere_margin() { + let matrix = VulkanStaticCamera::default().clip_from_world; + assert!(native_shadow_caster_visible( + &test_shadow_caster(1, [100.99, 0.0, 0.0]), + matrix + )); + assert!(!native_shadow_caster_visible( + &test_shadow_caster(2, [101.0, 0.0, 0.0]), + matrix + )); + } + + #[test] + fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() { + let parent = VulkanNodePose { + translation: [10.0, 20.0, 30.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }; + let node1 = VulkanNodePose { + // Native bounds replace this translation with the accumulated + // parent translation while retaining node 1's rotation. + translation: [99.0, -77.0, 42.0], + rotation: [0.0, 0.0, (0.5_f32).sqrt(), (0.5_f32).sqrt()], + }; + let first = mounted_shadow_sphere([2.0, 0.0, 0.0], 2.0, Some(parent), Some(node1)) + .expect("finite mounted sphere"); + assert!((first.center[0] - 10.0).abs() < 1.0e-5); + assert!((first.center[1] - 22.0).abs() < 1.0e-5); + assert!((first.center[2] - 30.0).abs() < 1.0e-5); + + let second = ShadowComponentSphere { + center: [12.0, 22.0, 30.0], + radius: 1.0, + }; + let caster = aggregate_shadow_caster( + 7, + 4, + LegacyIron3dEulerTransform { + translation: [1.0, 2.0, 3.0], + orientation_radians: [0.0; 3], + }, + [2.0, 3.0, 4.0], + vec![shadow_triangle( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + 0, + )], + &[first, second], + ) + .expect("aggregate caster"); + let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-4); + nearly_equal(caster.sphere.center[0], 22.333_334); + nearly_equal(caster.sphere.center[1], 68.0); + nearly_equal(caster.sphere.center[2], 123.0); + nearly_equal(caster.sphere.radius, 10.666_667); + } + + #[test] + fn internal_components_do_not_enter_msh_socket_mount_path() { + assert!(unit_component_is_visible(0, None)); + assert!(!unit_component_is_visible(1, None)); + assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO))); + } + #[test] fn parses_required_args() { assert_eq!( @@ -976,6 +5896,7 @@ mod tests { root: PathBuf::from("testdata/IS"), mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, + atmosphere_seconds: None, validation: false, readback_out: None, preview_roots: NonZeroUsize::MAX, @@ -986,6 +5907,301 @@ mod tests { ); } + #[test] + fn defaults_to_interactive_frame_loop() { + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + ])) + .expect("default interactive arguments"); + assert_eq!(parsed.frames, 0); + assert_eq!(parsed.preview_roots, NonZeroUsize::MAX); + assert_eq!(parsed.static_animation_frame, None); + } + + #[test] + fn lightmap_ranges_stay_on_the_decorated_variant_when_phase_changes() { + let empty_mat0 = Mat0Document { + version: 0, + animation_block_count: 0, + phases: Vec::new(), + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: Vec::new(), + }; + let mut bindings = vec![MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices: vec![10, 11], + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: empty_mat0, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: Some(0), + phase_uv_transforms: Vec::new(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }]; + let (binding_index, source_phases) = + find_material_animation_variant(&bindings, 10).expect("base material variant"); + assert_eq!(source_phases, vec![10, 11]); + bindings[binding_index].variants.push(MaterialVariantSet { + phase_material_indices: vec![20, 21], + range_indices: vec![7], + }); + + let lightmap_variant = &bindings[binding_index].variants[1]; + assert_eq!(lightmap_variant.range_indices, vec![7]); + assert_eq!( + selected_material_variant_index(lightmap_variant, 0), + Some(20) + ); + assert_eq!( + selected_material_variant_index(lightmap_variant, 1), + Some(21) + ); + } + + fn row_vector_transform(point: [f32; 4], matrix: [f32; 16]) -> [f32; 4] { + let mut result = [0.0; 4]; + for column in 0..4 { + result[column] = (0..4) + .map(|row| point[row] * matrix[row * 4 + column]) + .sum(); + } + result + } + + #[test] + fn free_flight_camera_uses_right_handed_z_up_basis_and_positive_depth() { + let camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_2, + near_plane: 1.0, + far_plane: 11.0, + move_speed: 1.0, + }; + + let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5); + assert_eq!(camera.forward(), [1.0, 0.0, 0.0]); + assert_eq!(camera.right(), [0.0, -1.0, 0.0]); + assert_eq!(camera.up(), [0.0, 0.0, 1.0]); + let matrix = camera.vulkan_camera(1.0).clip_from_world; + let near = row_vector_transform([1.0, 0.0, 0.0, 1.0], matrix); + let far = row_vector_transform([11.0, 0.0, 0.0, 1.0], matrix); + let camera_right = row_vector_transform([1.0, -1.0, 0.0, 1.0], matrix); + let world_up = row_vector_transform([1.0, 0.0, 1.0, 1.0], matrix); + nearly_equal(near[0], 0.0); + nearly_equal(near[1], 0.0); + nearly_equal(near[2], 0.0); + nearly_equal(near[3], 1.0); + assert!(camera_right[0] / camera_right[3] > 0.0); + assert!(world_up[1] / world_up[3] < 0.0); + nearly_equal(far[2] / far[3], 1.0); + } + + #[test] + fn sky_normals_keep_native_signed_byte_scale_without_renormalizing() { + assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]); + } + + #[test] + fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() { + let camera = FreeFlightCamera { + position: [10.0, 20.0, 30.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let terrain = TerrainWorld::default(); + assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0])); + assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0])); + + let terrain = TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + // The native Land.msh height is 32; renderer world Z is 1. + positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.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], + }], + }) + .expect("synthetic sun-occluder terrain"); + let blocked_camera = FreeFlightCamera { + position: [0.25, 0.25, 2.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + assert!(!native_sun_unoccluded( + &terrain, + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + // The same hit is beyond a short native sun segment, so it must not + // occlude the light even though the terrain contains a surface. + let short_segment_camera = FreeFlightCamera { + far_plane: 1.0, + ..blocked_camera + }; + assert!(native_sun_unoccluded( + &terrain, + &short_segment_camera, + [0.0, 0.0, -1.0] + )); + + let world_mesh = VulkanStaticMesh::smoke_triangle(); + assert!(!native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &world_mesh, + &[0], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + assert!(native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &world_mesh, + &[], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + let excluded_terrain = + TerrainWorld::from_land_msh(&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, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(0x20), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }) + .expect("excluded terrain"); + assert!(native_sun_unoccluded_with_world( + &excluded_terrain, + &world_mesh, + &[], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + let mut excluded_world_mesh = world_mesh.clone(); + excluded_world_mesh.draw_ranges[0].batch_flags = 0x0008; + assert!(native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &excluded_world_mesh, + &[0], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + } + + #[test] + fn world_sort_key_uses_the_first_indexed_vertex() { + let mesh = VulkanStaticMesh::smoke_triangle(); + let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0]) + .expect("first indexed vertex distance"); + assert!((distance - 3.0).abs() < 1.0e-6); + } + + #[test] + fn free_flight_camera_advances_wasd_and_vertical_controls() { + let mut camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 2.0, + }; + let mut keys = HashSet::new(); + keys.insert(KeyCode::KeyW); + keys.insert(KeyCode::KeyD); + camera.advance(&keys, 1.0); + let diagonal = 2.0 / 2.0_f32.sqrt(); + assert!((camera.position[0] - diagonal).abs() < 1.0e-5); + assert!((camera.position[1] + diagonal).abs() < 1.0e-5); + assert_eq!(camera.position[2], 0.0); + + keys.clear(); + keys.insert(KeyCode::KeyE); + keys.insert(KeyCode::ShiftLeft); + camera.advance(&keys, 0.5); + assert!((camera.position[2] - 4.0).abs() < 1.0e-5); + } + + #[test] + fn free_flight_mouse_right_turns_toward_previous_right_and_vertical_is_world_z() { + let mut camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.6, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 2.0, + }; + let old_right = camera.right(); + let old_forward = camera.forward(); + camera.look_delta(100.0, 0.0); + assert!(dot3(camera.forward(), old_right) > 0.0); + assert!(dot3(camera.forward(), old_forward) < 1.0); + + let mut keys = HashSet::new(); + keys.insert(KeyCode::KeyE); + camera.advance(&keys, 0.5); + assert!((camera.position[0]).abs() < 1.0e-5); + assert!((camera.position[1]).abs() < 1.0e-5); + assert!((camera.position[2] - 1.0).abs() < 1.0e-5); + } + #[test] fn parses_validation_flag() { let parsed = Args::parse(&strings(&[ @@ -1044,6 +6260,61 @@ mod tests { assert_eq!(parsed.static_animation_frame, Some(12)); } + #[test] + fn static_preview_control_defaults_apply_native_flags_and_last_row_wins() { + let binding = + |node_index, frame_a, frame_b, initial_blend, flags| PreparedControlNodeBinding { + node_index, + frame_a, + frame_b, + initial_blend, + flags, + }; + let times = static_preview_node_times( + 6, + &[ + binding(1, 1.0, 3.0, 0.0, 0), + binding(2, 1.0, 3.0, 1.25, 1), + binding(3, 1.0, 3.0, -0.5, 1), + binding(4, 1.0, 3.0, -0.5, 0), + binding(1, 1.0, 3.0, 0.25, 2), + binding(usize::MAX, f32::NAN, f32::INFINITY, f32::NAN, 4), + ], + ) + .expect("valid static CTLD defaults"); + let values = times + .iter() + .map(|time| time.map(|time| time.0)) + .collect::>(); + assert_eq!( + values, + vec![ + Some(0.0), + Some(2.5), + Some(1.5), + Some(2.0), + Some(1.0), + Some(0.0) + ] + ); + } + + #[test] + fn static_preview_rejects_negative_derived_control_sample_time() { + let error = static_preview_node_times( + 1, + &[PreparedControlNodeBinding { + node_index: 0, + frame_a: 0.0, + frame_b: 2.0, + initial_blend: -2.0, + flags: 1, + }], + ) + .expect_err("one wrap still leaves an unsupported negative sample time"); + assert!(error.contains("negative or non-finite sample time")); + } + #[test] fn parses_static_material_phase() { let parsed = Args::parse(&strings(&[ @@ -1146,11 +6417,13 @@ mod tests { &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 4)], + None, )?; append_static_preview_component( &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 9)], + None, )?; assert_eq!(merged.vertices.len(), 6); @@ -1168,7 +6441,10 @@ mod tests { let vertex = fparkan_render_vulkan::VulkanStaticVertex { position: [0.0, 0.0, 0.0], color: [1.0, 1.0, 1.0], + normal: [0.0, 0.0, 1.0], uv: [0.0, 0.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }; let mut merged = VulkanStaticMesh { vertices: vec![vertex; usize::from(u16::MAX) + 1], @@ -1180,9 +6456,89 @@ mod tests { &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 0)], + None, )?; assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]); Ok(()) } + + #[test] + fn precipitation_upload_reconstructs_native_ndc_depths_and_uvs() { + let camera = FreeFlightCamera { + position: [10.0, 20.0, 30.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_2, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let screen = ScreenBillboard { + world_head: [0.0; 3], + world_tail: [0.0; 3], + head: [0.0, 0.0], + tail: [0.0, 0.0], + corners: [[-0.25, -0.5], [0.25, -0.5], [-0.25, 0.5], [0.25, 0.5]], + uv: [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]], + half_size: 12.0, + head_depth: 8.0, + tail_depth: 4.0, + }; + let vertices = precipitation_quad_vertices( + PrecipitationKind::Rain, + &screen, + [1.0, 0.5, 0.25, 0.75], + &camera, + [100.0, 100.0], + ); + assert_eq!(vertices[0].uv, screen.uv[0]); + assert_eq!(vertices[3].uv, screen.uv[3]); + assert!( + (dot3( + sub3(vertices[0].position, camera.position), + camera.forward() + ) - 4.0) + .abs() + < 1.0e-5 + ); + assert!( + (dot3( + sub3(vertices[3].position, camera.position), + camera.forward() + ) - 8.0) + .abs() + < 1.0e-5 + ); + assert_eq!(vertices[0].overlay_alpha, 0.75); + } + + #[test] + fn sprite_upload_remaps_native_left_top_uv_order_to_quad_positions() { + let camera = FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]; + let vertices = sprite_quad_vertices_pixels( + &camera, + camera.forward(), + 10.0, + 1.0, + 1.0, + [100.0, 100.0], + [1.0; 3], + 1.0, + uv, + ); + assert_eq!( + vertices.map(|vertex| vertex.uv), + [uv[1], uv[2], uv[0], uv[3]] + ); + } } diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs index c07c5b8..ab606ad 100644 --- a/crates/fparkan-animation/src/lib.rs +++ b/crates/fparkan-animation/src/lib.rs @@ -254,12 +254,16 @@ impl AnimKey24 { ]; let time = AnimationTime(read_f32(bytes, 12)?); validate_time(time)?; - let raw_rotation = [ + let disk_wxyz = [ f32::from(read_i16(bytes, 16)?) / 32767.0, f32::from(read_i16(bytes, 18)?) / 32767.0, f32::from(read_i16(bytes, 20)?) / 32767.0, f32::from(read_i16(bytes, 22)?) / 32767.0, ]; + // Native AniMesh stores the scalar first (WXYZ). The portable pose + // contract is XYZW, matching the rotate/multiply helpers below. Keep + // the decoded values unnormalized; sampling_pose owns normalization. + let raw_rotation = [disk_wxyz[1], disk_wxyz[2], disk_wxyz[3], disk_wxyz[0]]; Ok(Self { time, pose: Pose { @@ -870,9 +874,47 @@ mod tests { assert_eq!(key.time, AnimationTime(12.5)); assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]); - assert!(key.pose.rotation[1] < 0.0); - assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05); - assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05); + assert!(key.pose.rotation[0] < 0.0); + assert!((key.pose.rotation[0] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05); + assert!((key.pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05); + } + + #[test] + fn anim_key24_preserves_disk_wxyz_as_internal_xyzw() { + let mut bytes = [0_u8; 24]; + bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes()); + bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes()); + bytes[18..20].copy_from_slice(&0_i16.to_le_bytes()); + bytes[20..22].copy_from_slice(&0_i16.to_le_bytes()); + bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes()); + + let key = AnimKey24::decode(&bytes).expect("bunker key"); + let expected = [0.0, 0.0, 3211.0 / 32767.0, 32609.0 / 32767.0]; + + assert_eq!(key.pose.rotation, expected); + } + + #[test] + fn bunker_fallback_pose_rotates_a_point_with_native_matrix_contract() { + let mut bytes = [0_u8; 24]; + bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes()); + bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes()); + bytes[18..20].copy_from_slice(&0_i16.to_le_bytes()); + bytes[20..22].copy_from_slice(&0_i16.to_le_bytes()); + bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes()); + + let pose = AnimKey24::decode(&bytes) + .expect("bunker key") + .sampling_pose(); + let rotated = rotate_point(pose.rotation, [0.0, 1.0, 0.0]); + + assert!((pose.rotation[0] - 0.0).abs() < 0.000_001); + assert!((pose.rotation[1] - 0.0).abs() < 0.000_001); + assert!((pose.rotation[2] - 0.097_995_8).abs() < 0.000_01); + assert!((pose.rotation[3] - 0.995_186_8).abs() < 0.000_01); + assert!((rotated[0] + 0.195_048_3).abs() < 0.000_01); + assert!((rotated[1] - 0.980_793_6).abs() < 0.000_01); + assert!(rotated[2].abs() < 0.000_01); } #[test] diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 3193e3f..8f45c8e 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -2,9 +2,13 @@ //! Asset manager ports and transactional preparation models. use fparkan_material::{ - decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable, + decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable, MAT0_KIND, WEAR_KIND, }; +pub use fparkan_material::{ + 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_msh::{decode_msh, validate_msh, ModelAsset, MshError}; @@ -20,21 +24,59 @@ use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, Res pub use fparkan_terrain::{TerrainError, TerrainWorld}; use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh}; pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError}; -use fparkan_texm::{decode_mip_rgba8, decode_texm, RgbaImage, TexmDocument, TexmError}; +use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError}; +pub use fparkan_texm::{PageRect, RgbaImage}; use fparkan_vfs::DirectoryVfs; -use std::collections::{hash_map::Entry, HashMap, HashSet}; +use std::collections::{hash_map::Entry, HashMap}; use std::fmt; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; const TEXTURES_ARCHIVE: &str = "textures.lib"; const LIGHTMAP_ARCHIVE: &str = "lightmap.lib"; +/// Material coefficients decoded from one selected MAT0 phase. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreparedMaterialPhase { + /// Raw NRes MAT0 entry flags retained from the archive directory. + pub attr1: u32, + /// Native material pipeline category `(attr1 >> 2) & 0xF`. + pub pipeline_category: u8, + /// Native material feature bit `attr1 & 0x40`. + pub native_flag_40: bool, + /// Phase parameters 4..6, normalized directional light RGB coefficient. + pub directional_rgb: [f32; 3], + /// Phase parameters 0..2, normalized additive RGB coefficient. + pub additive_rgb: [f32; 3], + /// Phase parameter 3, stored as a percentage. + pub diffuse_alpha: f32, + /// Phase parameters 8..10, normalized specular RGB coefficient. + pub specular_rgb: [f32; 3], + /// Phase parameter 16, integer specular power. + pub power: u8, + /// Signed phase parameter 17 selecting a TEXM Page rectangle, or -1. + pub page_index: i8, + /// Normalized atlas transform `[offset_x, offset_y, scale_x, scale_y]`. + pub page_uv_transform: [f32; 4], +} + type WearValidationCache = HashMap<(NormalizedPath, Vec), Result>; +/// Extracts the native pipeline category from a MAT0 NRes entry's `attr1`. +#[must_use] +pub const fn material_pipeline_category(attr1: u32) -> u8 { + ((attr1 >> 2) & 0x0F) as u8 +} + +/// Returns whether a MAT0 NRes entry carries native feature bit `0x40`. +#[must_use] +pub const fn material_native_flag_40(attr1: u32) -> bool { + (attr1 & 0x40) != 0 +} + /// Canonical terrain archive paths derived from a mission land reference. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionTerrainPaths { @@ -169,35 +211,587 @@ pub fn decode_nres_payload( decode_nres(bytes, ReadProfile::Compatible) } -/// Loads a phase-zero diffuse texture from a map-local standalone WEAR file. -/// -/// The sidecar stays beside `Land.msh`; MAT0 and TEXM remain in the normal -/// game-root archives. -pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root( +/// Loads every authored RGBA8 mip and the selected phase coefficients from a +/// map-local standalone WEAR table. +pub fn load_standalone_wear_material_texture_rgba8_and_phase_from_root( root: &Path, wear_path: &Path, material_index: u16, -) -> Result { +) -> Result<(Vec, PreparedMaterialPhase), String> { + load_standalone_wear_material_texture_rgba8_and_phase_at_index_from_root( + root, + wear_path, + material_index, + 0, + ) +} + +/// Loads an authored TEXM mip chain and MAT0 coefficients for one phase. +/// Empty phase texture names are treated as intentionally untextured. +pub fn load_standalone_wear_material_texture_rgba8_and_phase_at_index_from_root( + root: &Path, + wear_path: &Path, + material_index: u16, + phase_index: u16, +) -> Result<(Vec, PreparedMaterialPhase), String> { + let mut loader = StandaloneWearMaterialLoader::new(root); + loader.load(wear_path, material_index, phase_index) +} + +/// Reusable standalone WEAR/MAT0/TEXM loader for map-local preview paths. +/// +/// The native preview can request many rows from `Land1.wea` and `Land2.wea`. +/// Keeping one repository and one decoded table per path avoids repeating the +/// archive fingerprint, WEAR parse, and material lookup work for every row. +pub struct StandaloneWearMaterialLoader { + repository: CachedResourceRepository, + wears: HashMap, + materials: HashMap<(PathBuf, u16), ResolvedMaterial>, + loaded: HashMap<(PathBuf, u16, u16), (Vec, PreparedMaterialPhase)>, +} + +impl StandaloneWearMaterialLoader { + /// Creates a loader over an immutable install root. + #[must_use] + pub fn new(root: &Path) -> Self { + Self { + repository: CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))), + wears: HashMap::new(), + materials: HashMap::new(), + loaded: HashMap::new(), + } + } + + /// Loads one material row and phase from a map-local WEAR table. + /// + /// # Errors + /// + /// Returns a formatted error when the table, material, or texture cannot + /// be decoded. + pub fn load( + &mut self, + wear_path: &Path, + material_index: u16, + phase_index: u16, + ) -> Result<(Vec, PreparedMaterialPhase), String> { + let cache_key = wear_path.to_path_buf(); + if !self.wears.contains_key(&cache_key) { + let wear = decode_wear( + &std::fs::read(wear_path) + .map_err(|err| format!("{}: {err}", wear_path.display()))?, + ) + .map_err(|err| err.to_string())?; + self.wears.insert(cache_key.clone(), wear); + } + let material = self.resolve_material(&cache_key, material_index)?; + let loaded_key = (cache_key.clone(), material_index, phase_index); + if let Some(result) = self.loaded.get(&loaded_key) { + return Ok(result.clone()); + } + let result = load_resolved_material_texture_rgba8_and_phase( + &self.repository, + &material, + phase_index, + )?; + self.loaded.insert(loaded_key, result.clone()); + Ok(result) + } + + /// Returns the decoded MAT0 document for one map-local WEAR row. + /// + /// The document is cloned at this boundary so callers can retain a + /// frame-sampling binding while the loader continues to cache resources. + /// + /// # Errors + /// + /// Returns a formatted error when the WEAR table or material cannot be + /// resolved. + pub fn material_document( + &mut self, + wear_path: &Path, + material_index: u16, + ) -> Result { + let cache_key = wear_path.to_path_buf(); + self.ensure_wear(&cache_key)?; + self.resolve_material(&cache_key, material_index) + .map(|material| material.document) + } + + fn ensure_wear(&mut self, cache_key: &Path) -> Result<(), String> { + if self.wears.contains_key(cache_key) { + return Ok(()); + } + let wear = decode_wear( + &std::fs::read(cache_key).map_err(|err| format!("{}: {err}", cache_key.display()))?, + ) + .map_err(|err| err.to_string())?; + self.wears.insert(cache_key.to_path_buf(), wear); + Ok(()) + } + + fn resolve_material( + &mut self, + cache_key: &Path, + material_index: u16, + ) -> Result { + let material_key = (cache_key.to_path_buf(), material_index); + if let Some(material) = self.materials.get(&material_key) { + return Ok(material.clone()); + } + let wear = self + .wears + .get(cache_key) + .ok_or_else(|| "standalone WEAR cache insertion failed".to_string())?; + let material = resolve_material(&self.repository, wear, material_index) + .map_err(|err| err.to_string())?; + self.materials.insert(material_key, material.clone()); + Ok(material) + } +} + +/// Loads named map-local materials together with their complete MAT0 +/// documents. This is the runtime form used by sky/environment bindings; +/// the older helper remains available for callers that only need phase zero. +pub fn load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root( + root: &Path, + wear_path: &Path, + phase_index: u16, +) -> Result, Vec, PreparedMaterialPhase, Mat0Document)>, String> { let wear = decode_wear( &std::fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?, ) .map_err(|err| err.to_string())?; let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); - let material = - resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?; - let texture = material.document.primary_texture().ok_or_else(|| { - "MAT0 phase zero declares an intentionally untextured material".to_string() - })?; - let key = ResourceKey { + wear.entries + .iter() + .enumerate() + .map(|(index, entry)| { + let material_index = + u16::try_from(index).map_err(|_| "WEAR material index exceeds u16".to_string())?; + let material = resolve_material(&repository, &wear, material_index) + .map_err(|err| err.to_string())?; + let (mips, phase) = load_resolved_material_texture_rgba8_and_phase( + &repository, + &material, + phase_index, + )?; + Ok((entry.material.0.clone(), mips, phase, material.document)) + }) + .collect() +} + +/// Loads every named MAT0 entry in a map-local WEAR table through one cached +/// resource repository. Sky material rows use this path: their names are +/// material.lib entries, not direct textures.lib TEXM names. +pub fn load_standalone_wear_named_material_textures_rgba8_and_phases_from_root( + root: &Path, + wear_path: &Path, + phase_index: u16, +) -> Result, Vec, PreparedMaterialPhase)>, String> { + let wear = decode_wear( + &std::fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?, + ) + .map_err(|err| err.to_string())?; + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); + wear.entries + .iter() + .enumerate() + .map(|(index, entry)| { + let material_index = + u16::try_from(index).map_err(|_| "WEAR material index exceeds u16".to_string())?; + let material = resolve_material(&repository, &wear, material_index) + .map_err(|err| err.to_string())?; + let (mips, phase) = load_resolved_material_texture_rgba8_and_phase( + &repository, + &material, + phase_index, + )?; + Ok((entry.material.0.clone(), mips, phase)) + }) + .collect() +} + +fn load_resolved_material_texture_rgba8_and_phase( + repository: &R, + material: &ResolvedMaterial, + phase_index: u16, +) -> Result<(Vec, PreparedMaterialPhase), String> { + let phase = material + .document + .phases + .get(usize::from(phase_index)) + .or_else(|| material.document.phases.first()) + .ok_or_else(|| "MAT0 declares no material phases".to_string())?; + let texture_bytes = phase + .texture_raw + .iter() + .copied() + .take_while(|byte| *byte != 0) + .collect::>(); + let document = if texture_bytes.is_empty() { + None + } else { + let texture = ResourceName(texture_bytes); + let key = ResourceKey { + archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?, + name: texture.clone(), + type_id: None, + }; + let bytes = read_optional_key(repository, &key, Some("texture")) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("missing texture {texture:?}"))?; + Some(decode_texm(bytes).map_err(|err| err.to_string())?) + }; + let page_index = i8::from_ne_bytes([phase.parameters[17]]); + let page_uv = match document.as_ref() { + Some(document) => page_uv_transform( + document.width(), + document.height(), + &document.page_rects(), + page_index, + )?, + None => [0.0, 0.0, 1.0, 1.0], + }; + let phase = PreparedMaterialPhase { + attr1: material.attr1, + pipeline_category: material_pipeline_category(material.attr1), + native_flag_40: material_native_flag_40(material.attr1), + directional_rgb: [ + f32::from(phase.parameters[4]) / 255.0, + f32::from(phase.parameters[5]) / 255.0, + f32::from(phase.parameters[6]) / 255.0, + ], + additive_rgb: [ + f32::from(phase.parameters[0]) / 255.0, + f32::from(phase.parameters[1]) / 255.0, + f32::from(phase.parameters[2]) / 255.0, + ], + diffuse_alpha: f32::from(phase.parameters[3]) * 0.01, + specular_rgb: [ + f32::from(phase.parameters[8]) / 255.0, + f32::from(phase.parameters[9]) / 255.0, + f32::from(phase.parameters[10]) / 255.0, + ], + power: phase.parameters[16], + page_index, + page_uv_transform: page_uv, + }; + let mips = match document { + Some(document) => (0..document.mip_count()) + .map(|level| decode_mip_rgba8(&document, u32::try_from(level).unwrap_or(u32::MAX))) + .collect::, _>>() + .map_err(|err| err.to_string())?, + None => vec![RgbaImage { + width: 1, + height: 1, + rgba8: vec![255, 255, 255, 255], + }], + }; + Ok((mips, phase)) +} + +/// Loads a named `material.lib` MAT0 entry and its selected texture phase. +/// This is used by FX resources whose opcode carries a direct material +/// reference instead of a WEAR-table index. +pub fn load_material_name_texture_rgba8_and_phase_from_root( + root: &Path, + material_name: &str, + phase_index: u16, +) -> Result<(Vec, PreparedMaterialPhase), String> { + load_material_name_texture_rgba8_and_phase_with_document_from_root( + root, + material_name, + phase_index, + ) + .map(|(mips, phase, _document)| (mips, phase)) +} + +/// Loads a named `material.lib` entry, its selected texture phase, and the +/// complete MAT0 document used by the runtime animation sampler. +pub fn load_material_name_texture_rgba8_and_phase_with_document_from_root( + root: &Path, + material_name: &str, + phase_index: u16, +) -> Result<(Vec, PreparedMaterialPhase, Mat0Document), String> { + if material_name.is_empty() { + return Err("material name is empty".to_string()); + } + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); + let archive = parse_path("material.lib").map_err(|err| err.to_string())?; + let archive_id = repository + .open_archive(&archive) + .map_err(|err| err.to_string())?; + let name = ResourceName(material_name.as_bytes().to_vec()); + let entry = repository + .find(archive_id, &name) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("missing material {material_name:?}"))?; + let info = repository + .entry_info(entry) + .map_err(|err| err.to_string())?; + if info.key.type_id != Some(MAT0_KIND) { + return Err(format!("material {material_name:?} is not a MAT0 entry")); + } + let document = decode_mat0( + repository + .read(entry) + .map_err(|err| err.to_string())? + .as_slice(), + info.attr2, + ) + .map_err(|err| err.to_string())?; + let material = ResolvedMaterial { + name: info.key.name, + fallback: MaterialFallback::Exact, + attr1: info.attr1, + document, + }; + let document = material.document.clone(); + let (mips, phase) = + load_resolved_material_texture_rgba8_and_phase(&repository, &material, phase_index)?; + Ok((mips, phase, document)) +} + +/// Reads one named entry from a legacy resource archive through the same +/// cached repository used by the material loaders. +pub fn load_resource_entry_bytes_from_root( + root: &Path, + archive_name: &str, + entry_name: &str, +) -> Result, String> { + if archive_name.is_empty() || entry_name.is_empty() { + return Err("resource archive and entry names must be non-empty".to_string()); + } + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); + let archive = parse_path(archive_name).map_err(|err| err.to_string())?; + let archive_id = repository + .open_archive(&archive) + .map_err(|err| err.to_string())?; + let entry = repository + .find(archive_id, &ResourceName(entry_name.as_bytes().to_vec())) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("missing resource {archive_name}/{entry_name}"))?; + Ok(Arc::from( + repository + .read(entry) + .map_err(|err| err.to_string())? + .into_owned() + .into_boxed_slice(), + )) +} + +/// Loads every authored mip of a named entry from the normal texture archive. +/// +/// Environment material tables (`sky.wea`) contain direct texture entry names +/// rather than WEAR indirections, so they use this small archive helper. +pub fn load_texture_rgba8_mips_from_root( + root: &Path, + texture_name: &str, +) -> Result, String> { + if texture_name.is_empty() { + return Err("texture name is empty".to_string()); + } + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); + let archive = ResourceKey { archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?, - name: texture.clone(), + name: ResourceName(texture_name.as_bytes().to_vec()), type_id: None, }; - let bytes = read_optional_key(&repository, &key, Some("texture")) + let archive_id = repository + .open_archive(&archive.archive) + .map_err(|err| err.to_string())?; + let entry = repository + .find(archive_id, &archive.name) .map_err(|err| err.to_string())? - .ok_or_else(|| format!("missing texture {texture:?}"))?; - let document = decode_texm(bytes).map_err(|err| err.to_string())?; - decode_mip_rgba8(&document, 0).map_err(|err| err.to_string()) + .ok_or_else(|| format!("missing texture {texture_name:?}"))?; + let bytes = repository.read(entry).map_err(|err| err.to_string())?; + let document = decode_texm(Arc::from(bytes.as_slice().to_vec().into_boxed_slice())) + .map_err(|err| err.to_string())?; + (0..document.mip_count()) + .map(|level| decode_mip_rgba8(&document, u32::try_from(level).unwrap_or(u32::MAX))) + .collect::, _>>() + .map_err(|err| err.to_string()) +} + +/// Returns the native normalized atlas transform for a signed MAT0 page index. +/// +/// The transform is `[offset_x, offset_y, scale_x, scale_y]` and is applied as +/// `atlas_uv = offset + page_local_uv * scale`. A negative selector addresses +/// the complete texture. +pub fn page_uv_transform( + width: u32, + height: u32, + page_rects: &[PageRect], + page_index: i8, +) -> Result<[f32; 4], String> { + if width == 0 || height == 0 { + return Err("TEXM page transform has zero texture extent".to_string()); + } + if page_index < 0 { + return Ok([0.0, 0.0, 1.0, 1.0]); + } + let rect = page_rects + .get(usize::from(page_index as u8)) + .ok_or_else(|| { + format!( + "MAT0 page selector {page_index} exceeds TEXM Page rect count {}", + page_rects.len() + ) + })?; + if rect.x < 0 || rect.y < 0 || rect.w <= 0 || rect.h <= 0 { + return Err(format!( + "TEXM Page rect is invalid: x={} y={} w={} h={}", + rect.x, rect.y, rect.w, rect.h + )); + } + if i64::from(rect.x) + i64::from(rect.w) > i64::from(width) + || i64::from(rect.y) + i64::from(rect.h) > i64::from(height) + { + return Err(format!( + "TEXM Page rect exceeds base extent {width}x{height}: x={} y={} w={} h={}", + rect.x, rect.y, rect.w, rect.h + )); + } + Ok([ + f32::from(rect.x) / width as f32, + f32::from(rect.y) / height as f32, + f32::from(rect.w) / width as f32, + f32::from(rect.h) / height as f32, + ]) +} + +/// Crops decoded TEXM mips to a signed MAT0 page selector. +/// +/// A selector of `-1` means that the texture has no page selection and is +/// returned unchanged. Non-negative selectors address the TEXM `Page` table; +/// rectangles are scaled with floor origin and ceil end for each mip level. +/// The returned images use page-local 0..1 UVs, matching the native atlas +/// sampling contract. +pub fn crop_rgba8_mips_to_page( + mips: Vec, + page_rects: &[PageRect], + page_index: i8, +) -> Result, String> { + if page_index < 0 { + return Ok(mips); + } + let (base_width_u32, base_height_u32) = mips + .first() + .map(|base| (base.width, base.height)) + .ok_or_else(|| "cannot select a page from an empty TEXM mip chain".to_string())?; + let rect = page_rects + .get(usize::from(page_index as u8)) + .ok_or_else(|| { + format!( + "MAT0 page selector {page_index} exceeds TEXM Page rect count {}", + page_rects.len() + ) + })?; + if rect.x < 0 || rect.y < 0 || rect.w <= 0 || rect.h <= 0 { + return Err(format!( + "TEXM Page rect is invalid: x={} y={} w={} h={}", + rect.x, rect.y, rect.w, rect.h + )); + } + let base_x_end = i64::from(rect.x) + i64::from(rect.w); + let base_y_end = i64::from(rect.y) + i64::from(rect.h); + if base_x_end > i64::from(base_width_u32) || base_y_end > i64::from(base_height_u32) { + return Err(format!( + "TEXM Page rect exceeds base extent {}x{}: x={} y={} w={} h={}", + base_width_u32, base_height_u32, rect.x, rect.y, rect.w, rect.h + )); + } + mips.into_iter() + .enumerate() + .map(|(level, image)| { + let scale_width = i64::from(image.width); + let scale_height = i64::from(image.height); + let base_width = i64::from(base_width_u32); + let base_height = i64::from(base_height_u32); + let x0 = floor_mul_div(i64::from(rect.x), scale_width, base_width); + let x1 = ceil_mul_div(base_x_end, scale_width, base_width); + let y0 = floor_mul_div(i64::from(rect.y), scale_height, base_height); + let y1 = ceil_mul_div(base_y_end, scale_height, base_height); + let x0 = u32::try_from(x0) + .map_err(|_| format!("TEXM Page mip {level} x origin is invalid"))?; + let y0 = u32::try_from(y0) + .map_err(|_| format!("TEXM Page mip {level} y origin is invalid"))?; + let x1 = + u32::try_from(x1).map_err(|_| format!("TEXM Page mip {level} x end is invalid"))?; + let y1 = + u32::try_from(y1).map_err(|_| format!("TEXM Page mip {level} y end is invalid"))?; + if x0 >= x1 || y0 >= y1 || x1 > image.width || y1 > image.height { + return Err(format!( + "TEXM Page mip {level} scaled rect is outside {}x{}: {x0},{y0}..{x1},{y1}", + image.width, image.height + )); + } + let width = x1 - x0; + let height = y1 - y0; + let row_bytes = usize::try_from(width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or_else(|| format!("TEXM Page mip {level} row size overflows"))?; + let capacity = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| format!("TEXM Page mip {level} size overflows"))?; + let mut rgba8 = Vec::with_capacity(capacity); + let source_stride = usize::try_from(image.width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or_else(|| format!("TEXM Page mip {level} source stride overflows"))?; + let start_x = usize::try_from(x0) + .ok() + .and_then(|x| x.checked_mul(4)) + .ok_or_else(|| format!("TEXM Page mip {level} x offset overflows"))?; + for row in y0..y1 { + let row_start = usize::try_from(row) + .ok() + .and_then(|row| row.checked_mul(source_stride)) + .and_then(|offset| offset.checked_add(start_x)) + .ok_or_else(|| format!("TEXM Page mip {level} row offset overflows"))?; + let row_end = row_start + .checked_add(row_bytes) + .ok_or_else(|| format!("TEXM Page mip {level} row end overflows"))?; + let source = image + .rgba8 + .get(row_start..row_end) + .ok_or_else(|| format!("TEXM Page mip {level} row exceeds decoded pixels"))?; + rgba8.extend_from_slice(source); + } + Ok(RgbaImage { + width, + height, + rgba8, + }) + }) + .collect() +} + +fn floor_mul_div(value: i64, scale: i64, base: i64) -> i64 { + let product = value.saturating_mul(scale); + let quotient = product / base; + if product < 0 && product % base != 0 { + quotient - 1 + } else { + quotient + } +} + +fn ceil_mul_div(value: i64, scale: i64, base: i64) -> i64 { + let product = value.saturating_mul(scale); + let quotient = product / base; + if product > 0 && product % base != 0 { + quotient + 1 + } else { + quotient + } } /// Decodes terrain documents and builds immutable terrain state. @@ -271,6 +865,8 @@ pub struct PreparedVisual { pub id: AssetId, /// Optional mesh resource backing the visual. pub mesh: Option, + /// Source registry entry type (`EXTO`/`INTO`/etc.) when preserved. + pub source_type: Option, /// Prepared model backing the visual, when geometry is present. pub model_id: Option>, /// Prepared WEAR table backing the visual, when geometry is present. @@ -295,6 +891,35 @@ pub struct PreparedVisual { pub lightmap_count: usize, } +/// Component-local animation bindings loaded from an explicitly referenced +/// CTLD resource. +/// +/// This data is kept beside mission component instances rather than on the +/// shared visual/model cache, because two instances of one mesh may use +/// different control resources. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedComponentControl { + /// CTLD resource that supplied these bindings. + pub source: ResourceKey, + /// Enabled source rows in their original order, including duplicate nodes. + pub bindings: Vec, +} + +/// One enabled CTLD row targeting a node in a component model. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreparedControlNodeBinding { + /// Target node index in the component model. + pub node_index: usize, + /// First source animation endpoint, kept as an f32 value. + pub frame_a: f32, + /// Second source animation endpoint, kept as an f32 value. + pub frame_b: f32, + /// Initial source blend value. + pub initial_blend: f32, + /// Raw native row flags. + pub flags: u32, +} + /// CPU-side validated model ready for a renderer upload path. #[derive(Clone, Debug, PartialEq)] pub struct PreparedModel { @@ -328,6 +953,12 @@ pub struct PreparedMaterial { pub source: ResourceKey, /// Parsed material key retained for compatibility with older callers. pub name: ResourceName, + /// Raw NRes MAT0 entry flags retained for runtime pipeline selection. + pub attr1: u32, + /// Native material pipeline category `(attr1 >> 2) & 0xF`. + pub pipeline_category: u8, + /// Native material feature bit `attr1 & 0x40`. + pub native_flag_40: bool, /// Decoded MAT0 payload. pub mat0: Mat0Document, /// Texture requests declared by MAT0 phases. @@ -393,6 +1024,10 @@ pub struct MissionAssets { pub visuals: Vec, /// Visual ids available for each mission object index. pub object_visuals: Vec>>, + /// Optional component-local controls aligned with each object's visual + /// component list. `None` means no CTLD pose override is available because + /// there is no explicit reference or its referenced archive/entry is absent. + pub object_component_controls: Vec>>, } impl MissionAssets { @@ -829,7 +1464,38 @@ fn prepare_mission_assets_with_repository_internal( prototype_visual_ids.push(visual_id); } + let mut prototype_component_controls = Vec::with_capacity(prototypes.len()); + for (prototype_index, prototype) in prototypes.iter().enumerate() { + let Some(source) = prototype.control_resource.clone() else { + prototype_component_controls.push(None); + continue; + }; + let PrototypeGeometry::Mesh(mesh_key) = &prototype.geometry else { + prototype_component_controls.push(None); + continue; + }; + let model = models + .iter() + .find(|model| model.source == *mesh_key) + .ok_or_else(|| { + AssetError::InvalidPrototype(format!( + "prototype {} references control resource {}:{} but its prepared model is missing", + String::from_utf8_lossy(&prototype.key.0.0), + source.archive.as_str(), + String::from_utf8_lossy(&source.name.0), + )) + })?; + let control = prepare_component_control_from_resource( + repository, + source, + model.validated.node_count, + prototype_index, + )?; + prototype_component_controls.push(control); + } + let mut object_visuals = Vec::with_capacity(root_prototype_spans.len()); + let mut object_component_controls = Vec::with_capacity(root_prototype_spans.len()); for (root_index, span) in root_prototype_spans.iter().enumerate() { if span.start > span.end || span.end > prototype_visual_ids.len() { return Err(AssetError::InvalidPrototype(format!( @@ -837,14 +1503,17 @@ fn prepare_mission_assets_with_repository_internal( ))); } let mut ids = Vec::new(); - let mut dedup = HashSet::new(); + let mut controls = Vec::new(); for index in span.clone() { let visual_id = prototype_visual_ids[index]; - if dedup.insert(visual_id) { - ids.push(visual_id); - } + // A unit DAT is an instance tree. Reusing one prepared visual + // must not collapse two component instances (for example the two + // identical cannons on w_m_wlk2). + ids.push(visual_id); + controls.push(prototype_component_controls[index].clone()); } object_visuals.push(ids); + object_component_controls.push(controls); } let assets = MissionAssets { @@ -854,6 +1523,7 @@ fn prepare_mission_assets_with_repository_internal( textures, visuals, object_visuals, + object_component_controls, }; let report = AssetPreparationReport { model_count: assets.models.len(), @@ -876,9 +1546,11 @@ enum PreparedVisualSignature { archive: Vec, name: Vec, type_id: Option, + source_type: Option, dependency_count: usize, }, NonGeometric { + source_type: Option, dependency_count: usize, }, } @@ -929,6 +1601,9 @@ struct PreparedWearSignature { #[derive(Clone, Debug, Eq, PartialEq)] struct PreparedMaterialSignature { source: ResourceSignature, + attr1: u32, + pipeline_category: u8, + native_flag_40: bool, texture_requests: Vec>, lightmap_requests: Vec>, } @@ -948,9 +1623,11 @@ fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignat archive: key.archive.identity_bytes().to_vec(), name: key.name.0.clone(), type_id: key.type_id, + source_type: proto.source_type, dependency_count: proto.dependencies.len(), }, PrototypeGeometry::NonGeometric => PreparedVisualSignature::NonGeometric { + source_type: proto.source_type, dependency_count: proto.dependencies.len(), }, } @@ -984,6 +1661,9 @@ fn prepared_wear_signature(wear: &PreparedWear) -> PreparedWearSignature { fn prepared_material_signature(material: &PreparedMaterial) -> PreparedMaterialSignature { PreparedMaterialSignature { source: resource_signature(&material.source), + attr1: material.attr1, + pipeline_category: material.pipeline_category, + native_flag_40: material.native_flag_40, texture_requests: material .texture_requests .iter() @@ -1462,6 +2142,7 @@ pub fn prepare_visual(proto: &EffectivePrototype) -> Result( id: material_id, source: material_key, name: material.name.clone(), + attr1: material.attr1, + pipeline_category: material_pipeline_category(material.attr1), + native_flag_40: material_native_flag_40(material.attr1), mat0: material.document.clone(), texture_requests: texture_requests.clone(), lightmap_requests: lightmap_requests.clone(), @@ -1626,6 +2310,7 @@ fn prepare_visual_with_repository_internal( visual: PreparedVisual { id: AssetId::new((identity_policy.visual)(proto)), mesh: Some(mesh_key.clone()), + source_type: proto.source_type, model_id: Some(model_id), wear_id: Some(wear_id), model_nodes: model.node_count, @@ -2097,6 +2782,123 @@ fn read_optional_key( Ok(Some(Arc::from(bytes.into_owned()))) } +fn prepare_component_control_from_resource( + repository: &R, + source: ResourceKey, + model_node_count: usize, + prototype_index: usize, +) -> Result, AssetError> { + let Some(payload) = read_optional_key(repository, &source, Some("component control"))? else { + return Ok(None); + }; + decode_component_control_payload(&payload, source, model_node_count, prototype_index).map(Some) +} + +fn decode_component_control_payload( + payload: &[u8], + source: ResourceKey, + model_node_count: usize, + prototype_index: usize, +) -> Result { + const HEADER_BYTES: usize = 5 * 4; + const FIXED_SECTION_BYTES: usize = 108; + const SECTION_FIXED_BYTES: usize = 156; + const SECTION_NODE_BYTES: usize = 16; + const ROW_BYTES: usize = 36; + + let invalid = |message: &str| { + AssetError::InvalidPrototype(format!( + "prototype {prototype_index} control {}:{}: {message}", + source.archive.as_str(), + String::from_utf8_lossy(&source.name.0), + )) + }; + if payload.len() < HEADER_BYTES { + return Err(invalid("payload is shorter than its five-word header")); + } + let mut header = [0_u32; 5]; + for (index, word) in header.iter_mut().enumerate() { + let offset = index * 4; + let bytes: [u8; 4] = payload[offset..offset + 4] + .try_into() + .expect("header slice is exactly four bytes"); + *word = u32::from_le_bytes(bytes); + } + + let section_count = + usize::try_from(header[0]).map_err(|_| invalid("section count overflows usize"))?; + let per_section_nodes = + usize::try_from(header[1]).map_err(|_| invalid("section node count overflows usize"))?; + let row_count = + usize::try_from(header[2]).map_err(|_| invalid("binding count overflows usize"))?; + let per_section_node_bytes = SECTION_NODE_BYTES + .checked_mul(per_section_nodes) + .ok_or_else(|| invalid("section node bytes overflow"))?; + let section_bytes = section_count + .checked_mul( + SECTION_FIXED_BYTES + .checked_add(per_section_node_bytes) + .ok_or_else(|| invalid("section size overflows"))?, + ) + .ok_or_else(|| invalid("section table bytes overflow"))?; + let matrix_bytes = section_count + .checked_mul(section_count) + .and_then(|count| count.checked_mul(4)) + .ok_or_else(|| invalid("section matrix bytes overflow"))?; + let row_start = HEADER_BYTES + .checked_add(FIXED_SECTION_BYTES) + .and_then(|offset| offset.checked_add(section_bytes)) + .and_then(|offset| offset.checked_add(matrix_bytes)) + .ok_or_else(|| invalid("binding table offset overflows"))?; + let row_bytes = row_count + .checked_mul(ROW_BYTES) + .ok_or_else(|| invalid("binding table bytes overflow"))?; + let row_end = row_start + .checked_add(row_bytes) + .ok_or_else(|| invalid("binding table end overflows"))?; + if row_end > payload.len() { + return Err(invalid("complete binding table extends past the payload")); + } + + let mut bindings = Vec::with_capacity(row_count); + for row_index in 0..row_count { + let row_start = row_start + row_index * ROW_BYTES; + let row = &payload[row_start..row_start + ROW_BYTES]; + let flags = u32::from_le_bytes(row[0x20..0x24].try_into().expect("row flags")); + if flags & 4 != 0 { + continue; + } + let node_index = u32::from_le_bytes(row[0..4].try_into().expect("row node index")) as usize; + if node_index >= model_node_count { + return Err(invalid("enabled binding node index is outside the model")); + } + let frame_a = f32::from_bits(u32::from_le_bytes(row[4..8].try_into().expect("frame A"))); + let frame_b = f32::from_bits(u32::from_le_bytes(row[8..12].try_into().expect("frame B"))); + let initial_blend = f32::from_bits(u32::from_le_bytes( + row[12..16].try_into().expect("initial blend"), + )); + if !frame_a.is_finite() + || !frame_b.is_finite() + || !initial_blend.is_finite() + || frame_a < 0.0 + || frame_b < 0.0 + { + return Err(invalid( + "enabled binding contains a non-finite or negative value", + )); + } + bindings.push(PreparedControlNodeBinding { + node_index, + frame_a, + frame_b, + initial_blend, + flags, + }); + } + + Ok(PreparedComponentControl { source, bindings }) +} + fn sibling_name(key: &ResourceKey, extension: &str) -> Result { let dot = key .name @@ -2114,6 +2916,7 @@ fn sibling_name(key: &ResourceKey, extension: &str) -> Result u64 { let mut hasher = StableHasher::default(); + proto.source_type.hash(&mut hasher); match &proto.geometry { PrototypeGeometry::Mesh(key) => { 1_u8.hash(&mut hasher); @@ -2197,6 +3000,105 @@ mod tests { use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs}; use std::path::PathBuf; + #[test] + fn component_control_rows_preserve_f32_values_and_source_order() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let payload = component_control_payload(&[ + (99, f32::NAN, -1.0, -1.0, 4), + (1, 1.25, 2.5, 0.0, 0), + (1, 2.0, 0.0, 0.75, 1), + ]); + + let control = + decode_component_control_payload(&payload, source, 2, 7).expect("valid enabled rows"); + + assert_eq!(control.bindings.len(), 2); + assert_eq!(control.bindings[0].node_index, 1); + assert_eq!(control.bindings[0].frame_a, 1.25); + assert_eq!(control.bindings[0].frame_b, 2.5); + assert_eq!(control.bindings[0].initial_blend, 0.0); + assert_eq!(control.bindings[1].node_index, 1); + assert_eq!(control.bindings[1].frame_a, 2.0); + assert_eq!(control.bindings[1].flags, 1); + } + + #[test] + fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let mut overflow = vec![0; 20]; + overflow[0..4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(decode_component_control_payload(&overflow, source.clone(), 2, 0).is_err()); + + let out_of_range_node = component_control_payload(&[(2, 0.0, 1.0, 0.0, 0)]); + assert!( + decode_component_control_payload(&out_of_range_node, source.clone(), 2, 0,).is_err() + ); + + let fallback_input = component_control_payload(&[(1, 0.0, 4.0, -0.5, 0)]); + let control = decode_component_control_payload(&fallback_input, source.clone(), 2, 0) + .expect("sampler decides out-of-map fallback and blend normalization"); + assert_eq!(control.bindings[0].frame_b, 4.0); + assert_eq!(control.bindings[0].initial_blend, -0.5); + + let non_finite = component_control_payload(&[(1, f32::NAN, 1.0, 0.0, 0)]); + assert!(decode_component_control_payload(&non_finite, source, 2, 0).is_err()); + } + + fn component_control_payload(rows: &[(u32, f32, f32, f32, u32)]) -> Vec { + const ROW_START: usize = 5 * 4 + 108; + const ROW_BYTES: usize = 36; + let mut payload = vec![0; ROW_START + rows.len() * ROW_BYTES]; + payload[8..12].copy_from_slice(&u32::try_from(rows.len()).expect("count").to_le_bytes()); + for (index, (node, frame_a, frame_b, blend, flags)) in rows.iter().enumerate() { + let row = ROW_START + index * ROW_BYTES; + payload[row..row + 4].copy_from_slice(&node.to_le_bytes()); + payload[row + 4..row + 8].copy_from_slice(&frame_a.to_bits().to_le_bytes()); + payload[row + 8..row + 12].copy_from_slice(&frame_b.to_bits().to_le_bytes()); + payload[row + 12..row + 16].copy_from_slice(&blend.to_bits().to_le_bytes()); + payload[row + 0x20..row + 0x24].copy_from_slice(&flags.to_le_bytes()); + } + payload + } + + #[test] + fn page_uv_transform_matches_normalized_native_atlas_record() { + assert_eq!( + page_uv_transform( + 8, + 4, + &[PageRect { + x: 2, + w: 4, + y: 1, + h: 2, + }], + 0, + ), + Ok([0.25, 0.25, 0.5, 0.5]) + ); + assert_eq!(page_uv_transform(8, 4, &[], -1), Ok([0.0, 0.0, 1.0, 1.0])); + } + + #[test] + fn page_uv_transform_rejects_missing_or_out_of_bounds_page() { + let rects = [PageRect { + x: 7, + w: 2, + y: 0, + h: 1, + }]; + assert!(page_uv_transform(8, 4, &[], 0).is_err()); + assert!(page_uv_transform(8, 4, &rects, 0).is_err()); + } + #[test] fn wear_validation_cache_replays_archive_qualified_failure() { let repository = repository_with_archives(&[]); @@ -2279,7 +3181,9 @@ mod tests { type_id: Some(0x4853_454D), }), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: Vec::new(), + control_resource: None, }; let second = EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")), @@ -2290,7 +3194,9 @@ mod tests { type_id: Some(0x4853_454D), }), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: Vec::new(), + control_resource: None, }; assert_ne!(stable_visual_id(&first), stable_visual_id(&second)); @@ -2352,7 +3258,9 @@ mod tests { key: fparkan_prototype::PrototypeKey(resource_name(b"tree")), geometry: PrototypeGeometry::Mesh(mesh_key.clone()), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: vec![mesh_key.clone()], + control_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -2465,7 +3373,9 @@ mod tests { key: fparkan_prototype::PrototypeKey(resource_name(b"tree")), geometry: PrototypeGeometry::Mesh(mesh_key), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: Vec::new(), + control_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); graph @@ -2616,7 +3526,9 @@ mod tests { key: fparkan_prototype::PrototypeKey(resource_name(b"tree")), geometry: PrototypeGeometry::Mesh(mesh_key), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: Vec::new(), + control_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -2649,16 +3561,20 @@ mod tests { } #[test] - fn prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads() { + fn prepare_mission_assets_keep_instance_controls_beside_deduplicated_visuals() { let mesh_key = ResourceKey { archive: parse_path("static.rlb").expect("archive"), name: resource_name(b"tree.msh"), type_id: Some(0x4853_454D), }; - let msh = minimal_model_archive(); + let mut node = vec![0; 38]; + node[8..].fill(0xff); + let msh = minimal_model_archive_with_nodes(&node); let mat0 = mat0_with_texture(b"TEX_A"); let texm = texm_payload(); let lightmap_texm = texm_payload(); + let first_control = component_control_payload(&[(0, 1.25, 2.5, 0.25, 0)]); + let second_control = component_control_payload(&[(0, 2.5, 3.75, 0.75, 0)]); let repo = repository_with_archives_meta(&[ ( "static.rlb", @@ -2704,27 +3620,122 @@ mod tests { attr2: 0, }], ), + ( + "controls.rlb", + &[ + TestNresEntry { + name: b"first.ctl", + payload: &first_control, + type_id: 0, + attr2: 0, + }, + TestNresEntry { + name: b"second.ctl", + payload: &second_control, + type_id: 0, + attr2: 0, + }, + ], + ), ]); - let prototype = EffectivePrototype { - key: fparkan_prototype::PrototypeKey(resource_name(b"tree")), - geometry: PrototypeGeometry::Mesh(mesh_key), - source: fparkan_prototype::PrototypeSource::DirectArchive, - dependencies: Vec::new(), - }; + let prototypes = [ + EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"tree-a")), + geometry: PrototypeGeometry::Mesh(mesh_key.clone()), + source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, + dependencies: vec![mesh_key.clone()], + control_resource: Some(ResourceKey { + archive: parse_path("controls.rlb").expect("control archive"), + name: resource_name(b"first.ctl"), + type_id: None, + }), + }, + EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"tree-b")), + geometry: PrototypeGeometry::Mesh(mesh_key.clone()), + source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, + dependencies: vec![mesh_key.clone()], + control_resource: Some(ResourceKey { + archive: parse_path("controls.rlb").expect("control archive"), + name: resource_name(b"second.ctl"), + type_id: None, + }), + }, + EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"tree-c")), + geometry: PrototypeGeometry::Mesh(mesh_key.clone()), + source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, + dependencies: vec![mesh_key], + control_resource: Some(ResourceKey { + archive: parse_path("controls.rlb").expect("control archive"), + name: resource_name(b"missing.ctl"), + type_id: None, + }), + }, + ]; - let assets = prepare_mission_assets_with_repository( - &repo, - std::slice::from_ref(&(0..1)), - std::slice::from_ref(&prototype), - ) - .expect("prepared mission assets"); + let assets = prepare_mission_assets_with_repository(&repo, &[0..3], &prototypes) + .expect("prepared mission assets"); assert_eq!(assets.models.len(), 1); assert_eq!(assets.wears.len(), 1); assert_eq!(assets.materials.len(), 1); assert_eq!(assets.textures.len(), 2); assert_eq!(assets.visuals.len(), 1); - assert_eq!(assets.object_visuals, vec![vec![assets.visuals[0].id]]); + assert_eq!( + assets.object_visuals, + vec![vec![ + assets.visuals[0].id, + assets.visuals[0].id, + assets.visuals[0].id + ]] + ); + assert_eq!(assets.object_component_controls.len(), 1); + let controls = &assets.object_component_controls[0]; + assert_eq!(controls.len(), 3); + assert_eq!( + controls[0] + .as_ref() + .expect("first component control") + .source + .name, + resource_name(b"first.ctl") + ); + assert_eq!( + controls[1] + .as_ref() + .expect("second component control") + .source + .name, + resource_name(b"second.ctl") + ); + assert!( + controls[2].is_none(), + "missing explicit CTL means no override" + ); + assert_eq!( + controls[0].as_ref().expect("first control").bindings, + vec![PreparedControlNodeBinding { + node_index: 0, + frame_a: 1.25, + frame_b: 2.5, + initial_blend: 0.25, + flags: 0, + }] + ); + assert_eq!( + controls[1].as_ref().expect("second control").bindings, + vec![PreparedControlNodeBinding { + node_index: 0, + frame_a: 2.5, + frame_b: 3.75, + initial_blend: 0.75, + flags: 0, + }] + ); let visual = &assets.visuals[0]; assert_eq!(visual.model_id, Some(assets.models[0].id)); @@ -2734,6 +3745,29 @@ mod tests { assert_eq!(visual.lightmap_ids.len(), 1); } + #[test] + fn non_geometric_explicit_control_does_not_break_visual_preparation() { + let repository = repository_with_archives(&[]); + let prototype = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"marker")), + geometry: PrototypeGeometry::NonGeometric, + source: fparkan_prototype::PrototypeSource::ObjectsRegistry, + source_type: None, + dependencies: Vec::new(), + control_resource: Some(ResourceKey { + archive: parse_path("controls.rlb").expect("control archive"), + name: resource_name(b"marker.ctl"), + type_id: None, + }), + }; + + let assets = prepare_mission_assets_with_repository(&repository, &[0..1], &[prototype]) + .expect("non-geometric visual remains valid"); + + assert_eq!(assets.object_visuals.len(), 1); + assert_eq!(assets.object_component_controls, vec![vec![None]]); + } + #[test] fn texture_preparation_cache_reuses_exact_request_within_one_mission() { let texm = texm_payload(); @@ -2917,7 +3951,9 @@ mod tests { key: fparkan_prototype::PrototypeKey(resource_name(b"tree")), geometry: PrototypeGeometry::Mesh(mesh_key.clone()), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: vec![mesh_key.clone()], + control_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -3124,7 +4160,9 @@ mod tests { type_id: Some(0x4853_454D), }), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: Vec::new(), + control_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")), @@ -3134,7 +4172,9 @@ mod tests { type_id: Some(0x4853_454D), }), source: fparkan_prototype::PrototypeSource::DirectArchive, + source_type: None, dependencies: Vec::new(), + control_resource: None, }, ]; (repo, prototypes) @@ -3196,6 +4236,10 @@ mod tests { } fn minimal_model_archive() -> Vec { + minimal_model_archive_with_nodes(&[]) + } + + fn minimal_model_archive_with_nodes(nodes: &[u8]) -> Vec { struct MshEntry<'a> { type_id: u32, attr3: u32, @@ -3208,7 +4252,7 @@ mod tests { type_id: 1, attr3: 38, name: b"Res1", - payload: &[], + payload: nodes, }, MshEntry { type_id: 2, diff --git a/crates/fparkan-fx/src/atmosphere.rs b/crates/fparkan-fx/src/atmosphere.rs new file mode 100644 index 0000000..53ae81b --- /dev/null +++ b/crates/fparkan-fx/src/atmosphere.rs @@ -0,0 +1,1756 @@ +//! Parser and sampler for the original sky.ske atmosphere schedule. +//! +//! The game stores a schedule rather than a list of named weather presets. +//! A schedule contains one or more compressed in-game days. Track durations +//! are real seconds, while key dates are hours, minutes, and seconds in the +//! in-game day. Sampling selects tracks in file order and interpolates the +//! two surrounding keys on the complete cyclic schedule, including adjacent +//! track boundaries. +//! +//! Fields not consumed by the original atmosphere renderer keep neutral names +//! such as raw, header_values, and parameters. They are retained so a later +//! renderer can use them without changing the file parser. + +use fparkan_binary::{read_lp_bytes, Cursor, DecodeError}; +use std::fmt; + +/// sky.ske header marker. +pub const SKE_HEADER_MARKER: i32 = -1; +/// Supported sky.ske version. +pub const SKE_VERSION: u32 = 5; +/// Number of color parameters emitted by the sky sampler. +pub const SKY_COLOR_COUNT: usize = 13; +/// Number of packed colors in every on-disk key palette. +pub const KEY_COLOR_COUNT: usize = 15; +/// Number of fixed length-prefixed strings in every key. +pub const KEY_STRING_COUNT: usize = 6; +/// Number of words in an on-disk date. +pub const DATE_WORD_COUNT: usize = 8; + +const MAX_TRACKS: u32 = 256; +const MAX_KEYS: u32 = 16_384; +const MAX_STRING_BYTES: u32 = 64 * 1024; +const MAX_REFERENCES: u32 = 256; +const DAY_SECONDS: f32 = 86_400.0; +const COLOR_MIN_COMPONENT: f32 = 80.0 / 255.0; +const WEATHER_ALPHA: f32 = 150.0 / 255.0; + +/// A decoded atmosphere error. +#[derive(Clone, Debug, PartialEq)] +pub enum AtmosphereError { + /// A bounded binary read failed. + Decode(DecodeError), + /// The file marker or version is not supported. + InvalidHeader { + /// Header marker read from the file. + marker: i32, + /// Header version read from the file. + version: u32, + }, + /// A track has a version other than one. + InvalidTrackVersion { + /// Track number. + track_index: usize, + /// Version read from the file. + version: u32, + }, + /// A key has a version other than three. + InvalidKeyVersion { + /// Track number. + track_index: usize, + /// Key number. + key_index: usize, + /// Version read from the file. + version: u32, + }, + /// A date contains an impossible time of day. + InvalidDate { + /// Context in which the date appeared. + context: &'static str, + /// Hour word. + hours: u32, + /// Minute word. + minutes: u32, + /// Second word. + seconds: u32, + }, + /// A key date is earlier than the previous key date. + NonMonotonicKey { + /// Track number. + track_index: usize, + /// Key number that is out of order. + key_index: usize, + /// Previous key time in seconds. + previous_seconds: u32, + /// Current key time in seconds. + current_seconds: u32, + }, + /// A track contains no key and therefore cannot be sampled. + EmptyTrack { + /// Track number. + track_index: usize, + }, + /// A floating point value in a key is NaN or infinite. + NonFiniteValue { + /// Track number. + track_index: usize, + /// Key number. + key_index: usize, + /// Neutral field name. + field: &'static str, + /// Array element. + index: usize, + }, + /// A real duration could not be represented. + InvalidDuration { + /// Track number. + track_index: usize, + }, +} + +impl From for AtmosphereError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +impl fmt::Display for AtmosphereError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::InvalidHeader { marker, version } => { + write!(f, "unsupported sky.ske header marker={marker} version={version}") + } + Self::InvalidTrackVersion { + track_index, + version, + } => write!(f, "unsupported sky.ske track {track_index} version {version}"), + Self::InvalidKeyVersion { + track_index, + key_index, + version, + } => write!( + f, + "unsupported sky.ske track {track_index} key {key_index} version {version}" + ), + Self::InvalidDate { + context, + hours, + minutes, + seconds, + } => write!( + f, + "invalid sky.ske {context} time {hours:02}:{minutes:02}:{seconds:02}" + ), + Self::NonMonotonicKey { + track_index, + key_index, + previous_seconds, + current_seconds, + } => write!( + f, + "sky.ske track {track_index} key {key_index} goes backward from {previous_seconds}s to {current_seconds}s" + ), + Self::EmptyTrack { track_index } => { + write!(f, "sky.ske track {track_index} has no keys") + } + Self::NonFiniteValue { + track_index, + key_index, + field, + index, + } => write!( + f, + "sky.ske track {track_index} key {key_index} has non-finite {field}[{index}]" + ), + Self::InvalidDuration { track_index } => { + write!(f, "sky.ske track {track_index} has an invalid duration") + } + } + } +} + +impl std::error::Error for AtmosphereError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(source) => Some(source), + Self::InvalidHeader { .. } + | Self::InvalidTrackVersion { .. } + | Self::InvalidKeyVersion { .. } + | Self::InvalidDate { .. } + | Self::NonMonotonicKey { .. } + | Self::EmptyTrack { .. } + | Self::NonFiniteValue { .. } + | Self::InvalidDuration { .. } => None, + } + } +} + +/// The eight words copied by the original date reader. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SkeDate { + /// Raw date words, preserved in file order. + pub words: [u32; DATE_WORD_COUNT], +} + +impl SkeDate { + /// Constructs a date from its raw words. + #[must_use] + pub const fn from_words(words: [u32; DATE_WORD_COUNT]) -> Self { + Self { words } + } + + /// In-game hour word. + #[must_use] + pub const fn hours(self) -> u32 { + self.words[3] + } + + /// In-game minute word. + #[must_use] + pub const fn minutes(self) -> u32 { + self.words[4] + } + + /// In-game second word. + #[must_use] + pub const fn seconds(self) -> u32 { + self.words[5] + } + + /// Returns the time of day in seconds. + #[must_use] + pub const fn seconds_of_day(self) -> u32 { + self.hours() * 3_600 + self.minutes() * 60 + self.seconds() + } + + /// Returns the hour/minute position used by the original atmosphere + /// scheduler. The serialized seconds word is deliberately ignored by the + /// scheduler and is retained only in [`Self::words`]. + #[must_use] + pub const fn hour_minute_seconds(self) -> u32 { + (self.hours() * 60 + self.minutes()) * 60 + } + + /// Returns the hour and minute duration marker used by the original track + /// constructor. + #[must_use] + pub const fn real_duration_marker(self) -> u32 { + self.hour_minute_seconds() + } +} + +/// A packed original ARGB color (0xAARRGGBB). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PackedColor(pub u32); + +impl PackedColor { + /// Returns the raw ARGB word. + #[must_use] + pub const fn raw(self) -> u32 { + self.0 + } + + /// Decodes the word to normalized RGBA components. + #[must_use] + pub fn rgba(self) -> [f32; 4] { + [ + f32::from((self.0 >> 16) as u8) / 255.0, + f32::from((self.0 >> 8) as u8) / 255.0, + f32::from(self.0 as u8) / 255.0, + f32::from((self.0 >> 24) as u8) / 255.0, + ] + } + + /// Interpolates two packed colors in normalized channel space and packs + /// the result back to ARGB. + #[must_use] + pub fn lerp(self, other: Self, factor: f32) -> Self { + let a = self.rgba(); + let b = other.rgba(); + Self::from_rgba([ + lerp(a[0], b[0], factor), + lerp(a[1], b[1], factor), + lerp(a[2], b[2], factor), + lerp(a[3], b[3], factor), + ]) + } + + /// Packs normalized RGBA components as ARGB. + #[must_use] + pub fn from_rgba(rgba: [f32; 4]) -> Self { + let channel = |value: f32| -> u32 { (value.clamp(0.0, 1.0) * 255.0).round() as u32 }; + Self( + (channel(rgba[3]) << 24) + | (channel(rgba[0]) << 16) + | (channel(rgba[1]) << 8) + | channel(rgba[2]), + ) + } +} + +/// A length-prefixed string from a key. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AtmosphereString { + /// Original bytes without a terminating NUL. + pub raw: Vec, + /// Lossy UTF-8 view. Resource names in the original files are ASCII. + pub text: String, +} + +impl AtmosphereString { + /// Returns the decoded text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.text + } + + /// Whether the length-prefixed value is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.raw.is_empty() + } +} + +/// A key kind from the original atmosphere dispatcher. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AtmosphereKind { + /// Starts or updates the sun object. + SunStart, + /// Stops the sun object. + SunStop, + /// An unclassified state key with raw kind two. + State2, + /// Starts rain. + RainStart, + /// Stops rain. + RainStop, + /// Starts snow. + SnowStart, + /// Stops snow. + SnowStop, + /// An interpolated state key with raw kind seven. + State7, + /// Starts lightning. + LightningStart, + /// Stops lightning. + LightningStop, + /// A kind not present in the known original dispatcher. + Unknown(u32), +} + +impl AtmosphereKind { + /// Converts the raw kind word without discarding unknown values. + #[must_use] + pub const fn from_raw(raw: u32) -> Self { + match raw { + 0 => Self::SunStart, + 1 => Self::SunStop, + 2 => Self::State2, + 3 => Self::RainStart, + 4 => Self::RainStop, + 5 => Self::SnowStart, + 6 => Self::SnowStop, + 7 => Self::State7, + 8 => Self::LightningStart, + 9 => Self::LightningStop, + other => Self::Unknown(other), + } + } + + /// Returns the original kind word. + #[must_use] + pub const fn raw(self) -> u32 { + match self { + Self::SunStart => 0, + Self::SunStop => 1, + Self::State2 => 2, + Self::RainStart => 3, + Self::RainStop => 4, + Self::SnowStart => 5, + Self::SnowStop => 6, + Self::State7 => 7, + Self::LightningStart => 8, + Self::LightningStop => 9, + Self::Unknown(raw) => raw, + } + } +} + +/// One exact key record from a version-five sky.ske. +#[derive(Clone, Debug, PartialEq)] +pub struct AtmosphereKey { + /// Key record version (three in the original files). + pub version: u32, + /// In-game date of this key. + pub date: SkeDate, + /// Decoded key kind. + pub kind: AtmosphereKind, + /// Original kind word. + pub kind_raw: u32, + /// Opaque key word following the kind. + pub raw: u32, + /// Four packed colors at the start of the key payload. + pub header_colors: [PackedColor; 4], + /// Two floating point values following header_colors. + pub header_values: [f32; 2], + /// Fifteen packed colors in the key palette. + pub colors: [PackedColor; KEY_COLOR_COUNT], + /// Six fixed length-prefixed strings. + pub strings: Vec, + /// Four trailing floating point values. + pub parameters: [f32; 4], + /// Length-prefixed resource references. + pub references: Vec, +} + +impl AtmosphereKey { + /// Returns non-empty resource references in file order. + #[must_use] + pub fn resource_references(&self) -> Vec<&AtmosphereString> { + self.references + .iter() + .filter(|reference| !reference.is_empty()) + .collect() + } +} + +/// One atmosphere track. +#[derive(Clone, Debug, PartialEq)] +pub struct AtmosphereTrack { + /// Track version (one in the original files). + pub version: u32, + /// Track start date copied from disk. + pub start: SkeDate, + /// Track end/duration date copied from disk. + pub end: SkeDate, + /// Keys in file order. + pub keys: Vec, + /// Real seconds assigned to this track by the original constructor. + pub duration_seconds: u32, +} + +impl AtmosphereTrack { + /// Returns a key's in-game time in seconds. + #[must_use] + pub fn key_seconds(&self, index: usize) -> Option { + self.keys.get(index).map(|key| key.date.seconds_of_day()) + } +} + +/// The final forty bytes of a sky.ske. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AtmosphereTrailer { + /// Trailer date copied from disk. + pub date: SkeDate, + /// Two opaque trailer words. + pub values: [u32; 2], +} + +/// A timeline record emitted by TypedAtmosphere::timeline and +/// TypedAtmosphere::events_between. +#[derive(Clone, Debug, PartialEq)] +pub struct AtmosphereEvent { + /// Track containing the key. + pub track_index: usize, + /// Key containing the event. + pub key_index: usize, + /// In-game time of the event in seconds. + pub day_seconds: f32, + /// Real time from the beginning of the repeating schedule. + pub real_seconds: f32, + /// Decoded event kind. + pub kind: AtmosphereKind, + /// Opaque key word. + pub raw: u32, + /// Non-empty resource references attached to the key. + pub resources: Vec, +} + +/// A sky sample with the thirteen color parameters used by the original +/// CSky sampler. +#[derive(Clone, Debug, PartialEq)] +pub struct SkySample { + /// Colors zero through eleven: four header colors, then two reordered + /// groups of four palette colors. + pub colors: [PackedColor; SKY_COLOR_COUNT], + /// The two floating point values copied from the key header values. + pub values: [f32; 2], + /// The two remaining packed color parameters. + pub packed: [PackedColor; 2], +} + +/// Birth payload generated by the native atmosphere dispatcher for a named +/// celestial object. +/// +/// The dispatcher does not serialize this payload in `sky.ske`. For a kind +/// zero key it chooses the angle pair from the fixed string in slot zero and +/// computes the lifetime from the following matching kind one key. Keeping +/// the generated values beside the sample lets the renderer reproduce the +/// native object without inventing a start time on its first active frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunBirth { + /// Absolute real schedule time at which this interval began. + pub start_seconds: f32, + /// Real schedule duration until the matching stop key. + pub lifetime_seconds: f32, + /// Native birth angles in degrees. + pub initial_angles_degrees: [f32; 2], + /// WEA material row selected by the dispatcher. + pub material_row: usize, +} + +/// A sampled sun parameter set. The same payload shape is used for the moon +/// because the original dispatcher creates both from the same object class. +#[derive(Clone, Debug, PartialEq)] +pub struct SunSample { + /// Whether the latest SunStart/SunStop lifecycle interval is active. + pub active: bool, + /// RGB color multiplied by the third trailing key parameter. + pub rgb: [f32; 3], + /// First two trailing key parameters, kept with neutral names. + pub values: [f32; 2], + /// Two packed parameters consumed by the original sun object. + pub packed: [PackedColor; 2], + /// The third trailing parameter used for RGB intensity. + pub intensity: f32, + /// Generated birth payload for the current named interval, if active. + pub birth: Option, +} + +/// A sampled moon parameter set with the native sun-object layout. +pub type MoonSample = SunSample; + +/// A sampled precipitation or lightning state. +#[derive(Clone, Debug, PartialEq)] +pub struct WeatherSample { + /// Whether the corresponding start/stop state is active. + pub active: bool, + /// Interpolated fourth trailing key parameter. + pub intensity: f32, + /// Effective color. Active weather follows the original 80/255 RGB floor + /// and 150/255 alpha used by the particle objects. + pub color: [f32; 4], + /// Interpolated source color before the particle floor is applied. + pub raw_color: [f32; 4], + /// Resource references for the currently active interval. + pub resources: Vec, +} + +/// A complete atmosphere sample. +#[derive(Clone, Debug, PartialEq)] +pub struct AtmosphereFrame { + /// Requested real schedule time after finite-value sanitization. + pub real_seconds: f32, + /// Selected track. + pub track_index: usize, + /// Real seconds within the selected track. + pub track_seconds: f32, + /// In-game seconds within the selected day. + pub day_seconds: f32, + /// Interpolated sky parameters. + pub sky: SkySample, + /// Interpolated sun parameters. + pub sun: SunSample, + /// Interpolated moon parameters and its independent lifecycle. + pub moon: MoonSample, + /// Rain state. + pub rain: WeatherSample, + /// Snow state. + pub snow: WeatherSample, + /// Lightning state. + pub lightning: WeatherSample, +} + +/// A parsed, repeating version-five atmosphere schedule. +#[derive(Clone, Debug, PartialEq)] +pub struct TypedAtmosphere { + /// Header marker. + pub marker: i32, + /// Header version. + pub version: u32, + /// Tracks in file order. + pub tracks: Vec, + /// Trailer copied from disk. + pub trailer: AtmosphereTrailer, +} + +impl TypedAtmosphere { + /// Parses a complete version-five sky.ske byte slice. + /// + /// The parser consumes every byte, bounds all declared counts and strings, + /// checks every floating point field, and preserves unknown kinds and + /// payload words. + /// + /// Returns an error for unsupported versions, malformed dates, + /// non-finite values, truncation, or trailing bytes. + pub fn parse(bytes: &[u8]) -> Result { + let mut cursor = Cursor::new(bytes); + let marker = cursor.read_i32_le()?; + let version = cursor.read_u32_le()?; + let track_count = cursor.read_u32_le()?; + if marker != SKE_HEADER_MARKER || version != SKE_VERSION { + return Err(AtmosphereError::InvalidHeader { marker, version }); + } + checked_count(track_count, MAX_TRACKS)?; + + let mut tracks = Vec::with_capacity(track_count as usize); + for track_index in 0..track_count as usize { + let track_version = cursor.read_u32_le()?; + if track_version != 1 { + return Err(AtmosphereError::InvalidTrackVersion { + track_index, + version: track_version, + }); + } + let key_count = cursor.read_u32_le()?; + checked_count(key_count, MAX_KEYS)?; + let start = read_date(&mut cursor, "track start")?; + let end = read_date(&mut cursor, "track end")?; + let duration_seconds = end.real_duration_marker(); + if duration_seconds == 0 { + return Err(AtmosphereError::InvalidDuration { track_index }); + } + if key_count == 0 { + return Err(AtmosphereError::EmptyTrack { track_index }); + } + + let mut keys = Vec::with_capacity(key_count as usize); + let mut previous_seconds = None; + for key_index in 0..key_count as usize { + let key = read_key(&mut cursor, track_index, key_index)?; + let current_seconds = key.date.hour_minute_seconds(); + if let Some(previous_seconds) = previous_seconds { + if current_seconds < previous_seconds { + return Err(AtmosphereError::NonMonotonicKey { + track_index, + key_index, + previous_seconds, + current_seconds, + }); + } + } + previous_seconds = Some(current_seconds); + keys.push(key); + } + tracks.push(AtmosphereTrack { + version: track_version, + start, + end, + keys, + duration_seconds, + }); + } + + let trailer = AtmosphereTrailer { + date: read_date(&mut cursor, "trailer")?, + values: [cursor.read_u32_le()?, cursor.read_u32_le()?], + }; + cursor.require_eof()?; + Ok(Self { + marker, + version, + tracks, + trailer, + }) + } + + /// Returns the duration of one complete repeating schedule. + #[must_use] + pub fn total_duration_seconds(&self) -> f32 { + self.tracks + .iter() + .map(|track| track.duration_seconds as f32) + .sum() + } + + /// Returns the integer real-time offset selected by the trailer for the + /// initial atmosphere clock. The trailer's first word selects a track; + /// its hour/minute words are scaled by that track's duration and floored + /// exactly as in the original constructor. + #[must_use] + pub fn initial_offset_seconds(&self) -> u32 { + let track_index = usize::try_from(self.trailer.date.words[0]).ok(); + let Some(track) = track_index.and_then(|index| self.tracks.get(index)) else { + return 0; + }; + let scaled = u64::from(track.duration_seconds) + .saturating_mul(u64::from(self.trailer.date.hour_minute_seconds())) + / DAY_SECONDS_U64; + let track_offset = track_index + .map(|index| self.track_offset_seconds(index)) + .unwrap_or(0); + u32::try_from(track_offset.saturating_add(scaled)).unwrap_or(u32::MAX) + } + + /// Samples at real seconds from the beginning of the repeating schedule. + /// + /// Each track occupies its original real duration. Its in-game day is + /// then traversed from 00:00 through 24:00. Non-finite input is treated + /// as zero so a renderer cannot poison a frame with NaN coordinates. + #[must_use] + pub fn sample(&self, real_seconds: f32) -> AtmosphereFrame { + let real_seconds = if real_seconds.is_finite() { + real_seconds + } else { + 0.0 + }; + if self.tracks.is_empty() { + return empty_frame(real_seconds); + } + let total = self.total_duration_seconds(); + let wrapped = real_seconds.rem_euclid(total); + let (track_index, track_seconds) = self.locate_track(wrapped); + let track = &self.tracks[track_index]; + let day_seconds = track_seconds / track.duration_seconds as f32 * DAY_SECONDS; + let points = self.schedule_points(); + self.sample_track( + real_seconds, + wrapped, + real_seconds, + track_index, + track_seconds, + day_seconds, + &points, + ) + } + + /// Samples a selected track directly in in-game seconds. + /// + /// This is useful for deterministic tests and tools that already have the + /// original game clock. The day wraps at 24 hours. + #[must_use] + pub fn sample_day(&self, track_index: usize, day_seconds: f32) -> AtmosphereFrame { + if self.tracks.is_empty() { + return empty_frame(0.0); + } + let track_index = track_index.min(self.tracks.len() - 1); + let track = &self.tracks[track_index]; + let day_seconds = if day_seconds.is_finite() { + day_seconds.rem_euclid(DAY_SECONDS) + } else { + 0.0 + }; + let track_seconds = day_seconds / DAY_SECONDS * track.duration_seconds as f32; + let schedule_seconds = self.track_offset_seconds(track_index) as f32 + track_seconds; + let wrapped = schedule_seconds.rem_euclid(self.total_duration_seconds()); + let points = self.schedule_points(); + self.sample_track( + schedule_seconds, + wrapped, + schedule_seconds, + track_index, + track_seconds, + day_seconds, + &points, + ) + } + + /// Returns every key in file order with real schedule offsets. + #[must_use] + pub fn timeline(&self) -> Vec { + self.schedule_points() + .into_iter() + .map(|point| { + event_for_key( + point.track_index, + point.key_index, + point.key, + point.real_seconds as f32, + ) + }) + .collect() + } + + /// Returns timeline records crossed by (start, end], including schedule + /// wraparound and track boundaries. + #[must_use] + pub fn events_between(&self, start: f32, end: f32) -> Vec { + if self.tracks.is_empty() || !start.is_finite() || !end.is_finite() || end <= start { + return Vec::new(); + } + let total = self.total_duration_seconds(); + if total <= 0.0 || !total.is_finite() { + return Vec::new(); + } + let base = self.timeline(); + if base.is_empty() { + return Vec::new(); + } + let first_cycle = (start / total).floor() as i64 - 1; + let last_cycle = (end / total).ceil() as i64 + 1; + let mut out = Vec::new(); + for cycle in first_cycle..=last_cycle { + let offset = cycle as f32 * total; + for event in &base { + let event_time = event.real_seconds + offset; + if event_time > start && event_time <= end { + let mut event = event.clone(); + event.real_seconds = event_time; + out.push(event); + } + } + } + out.sort_by(|a, b| { + a.real_seconds + .partial_cmp(&b.real_seconds) + .unwrap_or(std::cmp::Ordering::Equal) + }); + out + } + + fn locate_track(&self, wrapped_seconds: f32) -> (usize, f32) { + let mut offset = 0.0; + for (index, track) in self.tracks.iter().enumerate() { + let duration = track.duration_seconds as f32; + if wrapped_seconds < offset + duration || index + 1 == self.tracks.len() { + return (index, (wrapped_seconds - offset).clamp(0.0, duration)); + } + offset += duration; + } + (self.tracks.len() - 1, 0.0) + } + + fn track_offset_seconds(&self, track_index: usize) -> u64 { + self.tracks + .iter() + .take(track_index) + .map(|track| u64::from(track.duration_seconds)) + .sum() + } + + fn schedule_points(&self) -> Vec> { + let mut points = Vec::new(); + let mut track_offset = 0_u64; + for (track_index, track) in self.tracks.iter().enumerate() { + for (key_index, key) in track.keys.iter().enumerate() { + let key_offset = u64::from(track.duration_seconds) + .saturating_mul(u64::from(key.date.hour_minute_seconds())) + / DAY_SECONDS_U64; + points.push(SchedulePoint { + track_index, + key_index, + key, + real_seconds: track_offset.saturating_add(key_offset), + }); + } + track_offset = track_offset.saturating_add(u64::from(track.duration_seconds)); + } + points + } + + fn sample_track( + &self, + real_seconds: f32, + sample_seconds: f32, + birth_seconds: f32, + track_index: usize, + track_seconds: f32, + day_seconds: f32, + points: &[SchedulePoint<'_>], + ) -> AtmosphereFrame { + let interpolation = + interpolate_schedule(points, sample_seconds, self.total_duration_seconds()); + let sky = sky_sample(&interpolation); + let total_seconds = self.total_duration_seconds(); + let sun_birth = celestial_birth(points, birth_seconds, total_seconds, b"sun"); + let moon_birth = celestial_birth(points, birth_seconds, total_seconds, b"moon"); + let sun = sun_sample(&interpolation, sun_birth); + let moon = sun_sample(&interpolation, moon_birth); + let rain = weather_sample( + points, + sample_seconds, + self.total_duration_seconds(), + &[AtmosphereKind::RainStart, AtmosphereKind::RainStop], + ); + let snow = weather_sample( + points, + sample_seconds, + self.total_duration_seconds(), + &[AtmosphereKind::SnowStart, AtmosphereKind::SnowStop], + ); + let lightning = weather_sample( + points, + sample_seconds, + self.total_duration_seconds(), + &[ + AtmosphereKind::LightningStart, + AtmosphereKind::LightningStop, + ], + ); + AtmosphereFrame { + real_seconds, + track_index, + track_seconds, + day_seconds, + sky, + sun, + moon, + rain, + snow, + lightning, + } + } +} + +const DAY_SECONDS_U64: u64 = 86_400; + +#[derive(Clone, Copy, Debug)] +struct SchedulePoint<'a> { + track_index: usize, + key_index: usize, + key: &'a AtmosphereKey, + real_seconds: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct InterpolatedKey { + header_colors: [PackedColor; 4], + header_values: [f32; 2], + colors: [PackedColor; KEY_COLOR_COUNT], + parameters: [f32; 4], +} + +fn read_date(cursor: &mut Cursor<'_>, context: &'static str) -> Result { + let mut words = [0_u32; DATE_WORD_COUNT]; + for word in &mut words { + *word = cursor.read_u32_le()?; + } + let date = SkeDate::from_words(words); + if date.hours() > 24 + || date.minutes() > 59 + || date.seconds() > 59 + || (date.hours() == 24 && (date.minutes() != 0 || date.seconds() != 0)) + { + return Err(AtmosphereError::InvalidDate { + context, + hours: date.hours(), + minutes: date.minutes(), + seconds: date.seconds(), + }); + } + Ok(date) +} + +fn read_key( + cursor: &mut Cursor<'_>, + track_index: usize, + key_index: usize, +) -> Result { + let version = cursor.read_u32_le()?; + if version != 3 { + return Err(AtmosphereError::InvalidKeyVersion { + track_index, + key_index, + version, + }); + } + let date = read_date(cursor, "key")?; + let kind_raw = cursor.read_u32_le()?; + let raw = cursor.read_u32_le()?; + let mut header_colors = [PackedColor::default(); 4]; + for color in &mut header_colors { + *color = PackedColor(cursor.read_u32_le()?); + } + let header_values = [cursor.read_f32_le()?, cursor.read_f32_le()?]; + let mut colors = [PackedColor::default(); KEY_COLOR_COUNT]; + for color in &mut colors { + *color = PackedColor(cursor.read_u32_le()?); + } + let mut strings = Vec::with_capacity(KEY_STRING_COUNT); + for _ in 0..KEY_STRING_COUNT { + strings.push(read_string(cursor)?); + } + let parameters = [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ]; + for (index, value) in header_values.iter().enumerate() { + if !value.is_finite() { + return Err(AtmosphereError::NonFiniteValue { + track_index, + key_index, + field: "header_values", + index, + }); + } + } + for (index, value) in parameters.iter().enumerate() { + if !value.is_finite() { + return Err(AtmosphereError::NonFiniteValue { + track_index, + key_index, + field: "parameters", + index, + }); + } + } + let reference_count = cursor.read_u32_le()?; + checked_count(reference_count, MAX_REFERENCES)?; + let mut references = Vec::with_capacity(reference_count as usize); + for _ in 0..reference_count { + references.push(read_string(cursor)?); + } + Ok(AtmosphereKey { + version, + date, + kind: AtmosphereKind::from_raw(kind_raw), + kind_raw, + raw, + header_colors, + header_values, + colors, + strings, + parameters, + references, + }) +} + +fn read_string(cursor: &mut Cursor<'_>) -> Result { + let raw = read_lp_bytes(cursor, MAX_STRING_BYTES)?; + let text = String::from_utf8_lossy(&raw).into_owned(); + Ok(AtmosphereString { raw, text }) +} + +fn checked_count(count: u32, limit: u32) -> Result<(), AtmosphereError> { + if count > limit { + return Err(AtmosphereError::Decode(DecodeError::LimitExceeded { + count: u64::from(count), + limit: u64::from(limit), + })); + } + Ok(()) +} + +fn lerp(a: f32, b: f32, factor: f32) -> f32 { + a + (b - a) * factor.clamp(0.0, 1.0) +} + +fn interpolate_schedule( + points: &[SchedulePoint<'_>], + schedule_seconds: f32, + total_seconds: f32, +) -> InterpolatedKey { + let (before, after, factor) = surrounding_points(points, schedule_seconds, total_seconds); + InterpolatedKey { + header_colors: std::array::from_fn(|index| { + before.key.header_colors[index].lerp(after.key.header_colors[index], factor) + }), + header_values: std::array::from_fn(|index| { + lerp( + before.key.header_values[index], + after.key.header_values[index], + factor, + ) + }), + colors: std::array::from_fn(|index| { + before.key.colors[index].lerp(after.key.colors[index], factor) + }), + parameters: std::array::from_fn(|index| { + lerp( + before.key.parameters[index], + after.key.parameters[index], + factor, + ) + }), + } +} + +fn surrounding_points<'a>( + points: &'a [SchedulePoint<'a>], + schedule_seconds: f32, + total_seconds: f32, +) -> (&'a SchedulePoint<'a>, &'a SchedulePoint<'a>, f32) { + debug_assert!(!points.is_empty()); + if points.len() == 1 { + return (&points[0], &points[0], 0.0); + } + let sample_seconds = schedule_seconds.rem_euclid(total_seconds); + + // The native previous-key helper is called with current time + 1 and a + // strict comparison, which makes an integer key at the current time the + // previous key. `<=` expresses that behavior without a frame epsilon. + let mut previous_index = None; + let mut next_index = None; + for (index, point) in points.iter().enumerate() { + let point_seconds = point.real_seconds as f32; + if point_seconds <= sample_seconds { + previous_index = Some(index); + } + if next_index.is_none() && point_seconds >= sample_seconds { + // Prefer the last key when several track boundaries share the + // same real timestamp. This selects the newly entered track. + let mut end = index; + while end + 1 < points.len() && points[end + 1].real_seconds == point.real_seconds { + end += 1; + } + next_index = Some(end); + } + } + + let previous_index = previous_index.unwrap_or(points.len() - 1); + let next_index = next_index.unwrap_or(0); + let previous_wrap = if points[previous_index].real_seconds as f32 > sample_seconds { + total_seconds + } else { + 0.0 + }; + let next_wrap = if (points[next_index].real_seconds as f32) < sample_seconds { + total_seconds + } else { + 0.0 + }; + let previous_seconds = points[previous_index].real_seconds as f32 - previous_wrap; + let next_seconds = points[next_index].real_seconds as f32 + next_wrap; + let span = (next_seconds - previous_seconds).max(0.0); + let factor = if span > 0.0 { + ((sample_seconds - previous_seconds) / span).clamp(0.0, 1.0) + } else { + 0.0 + }; + (&points[previous_index], &points[next_index], factor) +} + +fn sky_sample(key: &InterpolatedKey) -> SkySample { + let colors = [ + key.header_colors[1], + key.header_colors[2], + key.header_colors[0], + key.header_colors[3], + key.colors[0], + key.colors[3], + key.colors[1], + key.colors[2], + key.colors[4], + key.colors[7], + key.colors[5], + key.colors[6], + key.colors[8], + ]; + SkySample { + colors, + values: key.header_values, + packed: [key.colors[11], key.colors[13]], + } +} + +fn sun_sample(key: &InterpolatedKey, birth: Option) -> SunSample { + let source = key.colors[12].rgba(); + let intensity = key.parameters[2]; + SunSample { + active: birth.is_some(), + rgb: [ + source[0] * intensity, + source[1] * intensity, + source[2] * intensity, + ], + values: [key.parameters[0], key.parameters[1]], + packed: [key.colors[10], key.colors[14]], + intensity, + birth, + } +} + +fn weather_sample( + points: &[SchedulePoint<'_>], + schedule_seconds: f32, + total_seconds: f32, + markers: &[AtmosphereKind; 2], +) -> WeatherSample { + let (active, resources) = active_interval(points, schedule_seconds, total_seconds, markers); + let interpolation = interpolate_schedule(points, schedule_seconds, total_seconds); + let raw_color = interpolation.colors[12].rgba(); + let intensity = if active { + interpolation.parameters[3].max(0.0) + } else { + 0.0 + }; + let color = if active { + [ + raw_color[0].max(COLOR_MIN_COMPONENT), + raw_color[1].max(COLOR_MIN_COMPONENT), + raw_color[2].max(COLOR_MIN_COMPONENT), + WEATHER_ALPHA, + ] + } else { + [0.0; 4] + }; + WeatherSample { + active, + intensity, + color, + raw_color, + resources, + } +} + +fn active_interval( + points: &[SchedulePoint<'_>], + schedule_seconds: f32, + total_seconds: f32, + markers: &[AtmosphereKind; 2], +) -> (bool, Vec) { + let sample_seconds = schedule_seconds.rem_euclid(total_seconds); + let mut latest = None; + for point in points { + if point.key.kind != markers[0] && point.key.kind != markers[1] { + continue; + } + if point.real_seconds as f32 <= sample_seconds { + latest = Some(point); + } + } + let Some(latest) = latest.or_else(|| { + points + .iter() + .rev() + .find(|point| point.key.kind == markers[0] || point.key.kind == markers[1]) + }) else { + return (false, Vec::new()); + }; + if latest.key.kind == markers[0] { + ( + true, + latest + .key + .resource_references() + .into_iter() + .cloned() + .collect(), + ) + } else { + (false, Vec::new()) + } +} + +/// Reconstructs the native birth payload for one named celestial object. +/// +/// The dispatcher compares the first fixed key string with `"sun"`; every +/// other celestial name uses the moon angle pair. It then scans forward for +/// the next matching stop event and stores the real-time difference as the +/// lifetime. Schedule points already contain the native integer floor, so no +/// fractional day-time conversion is introduced here. +fn celestial_birth( + points: &[SchedulePoint<'_>], + schedule_seconds: f32, + total_seconds: f32, + name: &[u8], +) -> Option { + if points.is_empty() || !total_seconds.is_finite() || total_seconds <= 0.0 { + return None; + } + let schedule_seconds = if schedule_seconds.is_finite() { + schedule_seconds + } else { + 0.0 + }; + let cycle = (schedule_seconds / total_seconds).floor(); + let sample_absolute = cycle * total_seconds + schedule_seconds.rem_euclid(total_seconds); + + let mut latest: Option<(f32, usize, AtmosphereKind)> = None; + for (index, point) in points.iter().enumerate() { + if !matches!( + point.key.kind, + AtmosphereKind::SunStart | AtmosphereKind::SunStop + ) || point + .key + .strings + .first() + .map(|string| string.raw.as_slice()) + != Some(name) + { + continue; + } + let base = point.real_seconds as f32; + // Only the surrounding cycles can contain the latest event. Keeping + // all three also handles sample_day values at either schedule edge. + for cycle_delta in -1..=1 { + let time = base + (cycle + cycle_delta as f32) * total_seconds; + if time > sample_absolute { + continue; + } + let replace = latest + .map(|(old_time, old_index, _)| { + time > old_time || (time == old_time && index >= old_index) + }) + .unwrap_or(true); + if replace { + latest = Some((time, index, point.key.kind)); + } + } + } + let (start_seconds, start_index, kind) = latest?; + if kind != AtmosphereKind::SunStart { + return None; + } + + let mut stop_seconds = None; + for point in points { + if point.key.kind != AtmosphereKind::SunStop + || point + .key + .strings + .first() + .map(|string| string.raw.as_slice()) + != Some(name) + { + continue; + } + let base = point.real_seconds as f32; + for cycle_delta in -1..=2 { + let time = base + (cycle + cycle_delta as f32) * total_seconds; + if time <= start_seconds { + continue; + } + if stop_seconds.map(|old| time < old).unwrap_or(true) { + stop_seconds = Some(time); + } + } + } + let lifetime_seconds = stop_seconds + .map(|stop| stop - start_seconds) + .filter(|lifetime| lifetime.is_finite() && *lifetime > 0.0)?; + let (initial_angles_degrees, material_row) = if name == b"sun" { + ([90.0, 30.0], 3) + } else { + ([0.0, 50.0], 4) + }; + let _ = start_index; + Some(SunBirth { + start_seconds, + lifetime_seconds, + initial_angles_degrees, + material_row, + }) +} + +fn event_for_key( + track_index: usize, + key_index: usize, + key: &AtmosphereKey, + real_seconds: f32, +) -> AtmosphereEvent { + AtmosphereEvent { + track_index, + key_index, + day_seconds: key.date.hour_minute_seconds() as f32, + real_seconds, + kind: key.kind, + raw: key.raw, + resources: key.resource_references().into_iter().cloned().collect(), + } +} + +fn empty_frame(real_seconds: f32) -> AtmosphereFrame { + AtmosphereFrame { + real_seconds, + track_index: 0, + track_seconds: 0.0, + day_seconds: 0.0, + sky: SkySample { + colors: [PackedColor::default(); SKY_COLOR_COUNT], + values: [0.0; 2], + packed: [PackedColor::default(); 2], + }, + sun: SunSample { + active: false, + rgb: [0.0; 3], + values: [0.0; 2], + packed: [PackedColor::default(); 2], + intensity: 0.0, + birth: None, + }, + moon: SunSample { + active: false, + rgb: [0.0; 3], + values: [0.0; 2], + packed: [PackedColor::default(); 2], + intensity: 0.0, + birth: None, + }, + rain: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + snow: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + lightning: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + fn date(hours: u32, minutes: u32, seconds: u32) -> [u32; DATE_WORD_COUNT] { + [0, 0, 0, hours, minutes, seconds, 0, 0] + } + + fn lp(bytes: &[u8], out: &mut Vec) { + out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(bytes); + } + + fn key( + out: &mut Vec, + hours: u32, + minutes: u32, + kind: u32, + palette: [u32; KEY_COLOR_COUNT], + tail: [f32; 4], + references: &[&[u8]], + ) { + key_with_seconds(out, hours, minutes, 0, kind, palette, tail, references); + } + + fn key_with_seconds( + out: &mut Vec, + hours: u32, + minutes: u32, + seconds: u32, + kind: u32, + palette: [u32; KEY_COLOR_COUNT], + tail: [f32; 4], + references: &[&[u8]], + ) { + out.extend_from_slice(&3_u32.to_le_bytes()); + for word in date(hours, minutes, seconds) { + out.extend_from_slice(&word.to_le_bytes()); + } + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&0xAABB_CCDD_u32.to_le_bytes()); + for value in [0xFF11_2233_u32, 0xFF44_5566, 0xFF77_8899, 0xFFAA_BBCC] { + out.extend_from_slice(&value.to_le_bytes()); + } + for value in [0.25_f32, 0.75_f32] { + out.extend_from_slice(&value.to_bits().to_le_bytes()); + } + for value in palette { + out.extend_from_slice(&value.to_le_bytes()); + } + for _ in 0..KEY_STRING_COUNT { + lp(b"", out); + } + for value in tail { + out.extend_from_slice(&value.to_bits().to_le_bytes()); + } + out.extend_from_slice(&(references.len() as u32).to_le_bytes()); + for reference in references { + lp(reference, out); + } + } + + fn document(key_count: u32, keys: impl FnOnce(&mut Vec)) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&SKE_HEADER_MARKER.to_le_bytes()); + out.extend_from_slice(&SKE_VERSION.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&key_count.to_le_bytes()); + for word in date(23, 59, 0) { + out.extend_from_slice(&word.to_le_bytes()); + } + for word in date(0, 15, 0) { + out.extend_from_slice(&word.to_le_bytes()); + } + keys(&mut out); + for word in date(1, 30, 0) { + out.extend_from_slice(&word.to_le_bytes()); + } + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out + } + + fn two_track_document(palette: [u32; KEY_COLOR_COUNT]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&SKE_HEADER_MARKER.to_le_bytes()); + out.extend_from_slice(&SKE_VERSION.to_le_bytes()); + out.extend_from_slice(&2_u32.to_le_bytes()); + + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&2_u32.to_le_bytes()); + for word in date(23, 59, 0) { + out.extend_from_slice(&word.to_le_bytes()); + } + for word in date(0, 1, 0) { + out.extend_from_slice(&word.to_le_bytes()); + } + key_with_seconds(&mut out, 23, 0, 0, 7, palette, [0.0, 0.0, 0.0, 0.0], &[]); + key_with_seconds(&mut out, 23, 59, 59, 7, palette, [0.0, 0.0, 0.0, 0.0], &[]); + + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&3_u32.to_le_bytes()); + for word in date(23, 59, 0) { + out.extend_from_slice(&word.to_le_bytes()); + } + for word in date(0, 2, 0) { + out.extend_from_slice(&word.to_le_bytes()); + } + key_with_seconds( + &mut out, + 0, + 0, + 0, + 3, + palette, + [0.0, 0.0, 2.0, 1.0], + &[b"rain"], + ); + key_with_seconds( + &mut out, + 6, + 0, + 0, + 7, + palette, + [0.0, 0.0, 2.0, 1.0], + &[b"future"], + ); + key_with_seconds( + &mut out, + 12, + 0, + 0, + 4, + palette, + [0.0, 0.0, 0.0, 0.0], + &[b"stop"], + ); + + for word in [1_u32, 0, 0, 12, 0, 0, 0, 0] { + out.extend_from_slice(&word.to_le_bytes()); + } + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out + } + + #[test] + fn parses_exact_v5_layout_and_rejects_trailing_bytes() { + let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT]; + let bytes = document(1, |out| { + key(out, 0, 0, 3, palette, [0.0, 0.0, 0.0, 1.0], &[b"rain"]) + }); + let parsed = TypedAtmosphere::parse(&bytes).expect("v5 parse"); + assert_eq!(parsed.marker, -1); + assert_eq!(parsed.version, 5); + assert_eq!(parsed.tracks.len(), 1); + assert_eq!(parsed.tracks[0].duration_seconds, 900); + assert_eq!(parsed.tracks[0].keys[0].references[0].as_str(), "rain"); + assert_eq!(parsed.trailer.date.hours(), 1); + assert!(matches!( + TypedAtmosphere::parse(&[bytes, vec![0]].concat()), + Err(AtmosphereError::Decode(DecodeError::TrailingBytes { .. })) + )); + } + + #[test] + fn maps_original_sky_color_order_and_interpolates() { + let first_palette = std::array::from_fn(|i| 0xFF00_0000_u32 | i as u32); + let second_palette = std::array::from_fn(|i| 0xFF00_1000_u32 | (i as u32) << 4); + let bytes = document(2, |out| { + key(out, 0, 0, 7, first_palette, [0.0, 0.0, 0.0, 0.0], &[]); + key(out, 12, 0, 7, second_palette, [1.0, 2.0, 3.0, 1.0], &[]); + }); + let parsed = TypedAtmosphere::parse(&bytes).expect("parse"); + let start = parsed.sample_day(0, 0.0); + assert_eq!(start.sky.colors[0].raw(), 0xFF44_5566); + assert_eq!(start.sky.colors[1].raw(), 0xFF77_8899); + assert_eq!(start.sky.colors[2].raw(), 0xFF11_2233); + assert_eq!(start.sky.colors[4].raw(), first_palette[0]); + assert_eq!(start.sky.colors[5].raw(), first_palette[3]); + assert_eq!(start.sky.colors[8].raw(), first_palette[4]); + assert_eq!(start.sky.colors[12].raw(), first_palette[8]); + assert_eq!(start.sun.intensity, 0.0); + let middle = parsed.sample_day(0, 6.0 * 3_600.0); + assert!((middle.sun.intensity - 1.5).abs() < 0.01); + assert_eq!(middle.rain.intensity, 0.0); + } + + #[test] + fn wraps_last_key_to_first_and_emits_events() { + let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT]; + let bytes = document(2, |out| { + key(out, 6, 0, 3, palette, [0.0, 0.0, 0.0, 0.0], &[b"rain"]); + key(out, 12, 0, 4, palette, [1.0, 1.0, 1.0, 0.0], &[]); + }); + let parsed = TypedAtmosphere::parse(&bytes).expect("parse"); + let near_midnight = parsed.sample_day(0, 23.0 * 3_600.0); + assert!(!near_midnight.rain.active); + let events = parsed.events_between(0.0, 300.0); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, AtmosphereKind::RainStart); + let wrapped = parsed.events_between(900.0, 1_300.0); + assert_eq!(wrapped.len(), 1); + assert_eq!(wrapped[0].kind, AtmosphereKind::RainStart); + } + + #[test] + fn samples_across_tracks_uses_integer_key_times_and_trailer_offset() { + let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT]; + let parsed = TypedAtmosphere::parse(&two_track_document(palette)).expect("parse"); + + assert_eq!(parsed.tracks[0].duration_seconds, 60); + assert_eq!(parsed.tracks[1].duration_seconds, 120); + assert_eq!(parsed.initial_offset_seconds(), 120); + assert_eq!(parsed.timeline()[1].real_seconds, 59.0); + + let before_transition = parsed.sample(59.5); + assert_eq!(before_transition.track_index, 0); + assert!((before_transition.sun.intensity - 1.0).abs() < 0.01); + + let active = parsed.sample_day(1, 6.0 * 3_600.0); + assert_eq!(active.real_seconds, 90.0); + assert!(active.rain.active); + assert_eq!(active.rain.resources.len(), 1); + assert_eq!(active.rain.resources[0].as_str(), "rain"); + assert!(parsed + .events_between(59.0, 61.0) + .iter() + .any(|event| event.kind == AtmosphereKind::RainStart)); + } + + #[test] + fn repeating_schedule_shifts_celestial_birth_with_absolute_clock() { + let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT]; + let bytes = document(2, |out| { + key(out, 0, 0, 0, palette, [0.0, 0.0, 1.0, 0.0], &[]); + key(out, 12, 0, 1, palette, [0.0, 0.0, 1.0, 0.0], &[]); + }); + let mut parsed = TypedAtmosphere::parse(&bytes).expect("parse"); + for (key, name) in parsed.tracks[0].keys.iter_mut().zip(["sun", "sun"]) { + key.strings[0] = AtmosphereString { + raw: name.as_bytes().to_vec(), + text: name.to_owned(), + }; + } + + let total = parsed.total_duration_seconds(); + let first = parsed.sample(300.0); + let later = parsed.sample(300.0 + total); + let first_birth = first.sun.birth.expect("sun birth"); + let later_birth = later.sun.birth.expect("wrapped sun birth"); + assert!((later_birth.start_seconds - first_birth.start_seconds - total).abs() < 1.0e-5); + assert_eq!(first_birth.lifetime_seconds, later_birth.lifetime_seconds); + assert_eq!( + first_birth.initial_angles_degrees, + later_birth.initial_angles_degrees + ); + assert_eq!(first_birth.material_row, later_birth.material_row); + + let first_frame = crate::sky::sun_frame( + &first.sun, + 300.0, + crate::sky::SunConfig::from_birth(first_birth), + ); + let later_frame = crate::sky::sun_frame( + &later.sun, + 300.0 + total, + crate::sky::SunConfig::from_birth(later_birth), + ); + assert!((first_frame.age - later_frame.age).abs() < 1.0e-5); + for (first, later) in first_frame.direction.iter().zip(later_frame.direction) { + assert!((*first - later).abs() < 1.0e-5); + } + } + + #[test] + fn rejects_non_finite_key_values() { + let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT]; + let mut bytes = document(1, |out| { + key(out, 0, 0, 7, palette, [f32::NAN, 0.0, 0.0, 0.0], &[]) + }); + assert!(matches!( + TypedAtmosphere::parse(&bytes), + Err(AtmosphereError::NonFiniteValue { + field: "parameters", + index: 0, + .. + }) + )); + bytes[0] = 0; + assert!(matches!( + TypedAtmosphere::parse(&bytes), + Err(AtmosphereError::InvalidHeader { .. }) + )); + } + + #[test] + #[ignore = "requires the original GOG installation"] + fn parses_all_original_gog_sky_files() { + let root = std::env::var_os("PARKAN_GOG_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + let mission_root = root.join("MISSIONS"); + let mut files = Vec::new(); + collect_ske(&mission_root, &mut files); + files.sort(); + assert_eq!(files.len(), 29); + for path in files { + let bytes = std::fs::read(&path).expect("read sky.ske"); + let parsed = TypedAtmosphere::parse(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + assert!(parsed.total_duration_seconds().is_finite()); + assert!(!parsed.timeline().is_empty(), "{}", path.display()); + let materials = std::fs::read(path.with_file_name("sky.wea")) + .ok() + .and_then(|bytes| crate::sky::SkyMaterials::parse(&bytes).ok()) + .unwrap_or_default(); + let mut sky = crate::sky::SkySystem::new(Default::default(), materials) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + for second in [0.0, 0.5, 900.0, 86_399.0, 172_800.0] { + let frame = parsed.sample(second); + assert!(frame.sky.values.iter().all(|value| value.is_finite())); + assert!(frame.sun.rgb.iter().all(|value| value.is_finite())); + assert!(frame.rain.intensity.is_finite()); + assert!(frame.snow.intensity.is_finite()); + assert!(frame.lightning.intensity.is_finite()); + let sky_frame = sky + .update(&frame, second) + .unwrap_or_else(|error| panic!("{} at {second}: {error}", path.display())); + assert_eq!(sky_frame.directional_lights.len(), 4); + let optics = sky_frame + .sun_optics(crate::sky::SunVisibility { + projected_sun: [640.0, 360.0], + viewport: [1280.0, 720.0], + view_forward: [0.0, 0.0, 1.0], + unoccluded: true, + }) + .expect("native sky optics"); + assert_eq!(optics.flares.len(), 12); + } + } + } + + fn collect_ske(root: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_ske(&path, out); + } else if path.file_name().is_some_and(|name| name == "sky.ske") { + out.push(path); + } + } + } +} diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs new file mode 100644 index 0000000..babbd0f --- /dev/null +++ b/crates/fparkan-fx/src/environment.rs @@ -0,0 +1,2803 @@ +//! CPU-side precipitation and lightning output. +//! +//! The original game keeps atmospheric objects close to the observer. This +//! module follows that ownership boundary: it advances precipitation particles +//! and native lightning effect lifetimes and returns renderer/audio records +//! for the current frame. It does not invent a scene graph or hide resource +//! loading behind the simulation. + +use crate::sky::SkyMaterials; +use crate::{FxDocument, FxError, FxOpcode}; +use std::sync::Arc; + +// Terrain.dll writes these velocity vectors into the shared precipitation +// particle state on every update (CSnow at 10045E27 and CRain at 10046555). +// Keep the values together so a renderer cannot accidentally substitute the +// old placeholder speeds. +pub const RAIN_VELOCITY: [f32; 3] = [0.5, 0.0, -60.0]; +pub const SNOW_VELOCITY: [f32; 3] = [0.5, 0.0, -4.0]; + +/// Near plane used by Terrain's precipitation volume constructor at +/// `100444C0`. +pub const PRECIPITATION_NEAR: f32 = 2.0; +/// Far plane used by Terrain's precipitation volume constructor at +/// `100444C0`. +pub const PRECIPITATION_FAR: f32 = 50.0; +/// Half horizontal angle used by Terrain's precipitation volume constructor. +pub const PRECIPITATION_HALF_ANGLE_X: f32 = 1.3 * 0.5; +/// Half vertical angle used by Terrain's precipitation volume constructor. +pub const PRECIPITATION_HALF_ANGLE_Y: f32 = 0.975 * 0.5; +/// Native rain size factor (`Terrain.dll!65E30`). +pub const RAIN_SIZE_FACTOR: f32 = 0.0065; +/// Native snow size factor (`Terrain.dll!65DE8`). +pub const SNOW_SIZE_FACTOR: f32 = 0.0195; +/// Native snow minimum depth fade (`Terrain.dll!65D98`). +pub const SNOW_MIN_DEPTH_FADE: f32 = 0.1; +/// Native precipitation particle-count multiplier (`Terrain.dll!65DE4`). +pub const PARTICLE_COUNT_SCALE: f32 = 1_000.0; + +/// Native precipitation spawn volume. The fields mirror the descriptor +/// built by Terrain at `100444C0`; particles are sampled from this box by +/// `100445B0` using three 16-bit random values divided by 32768. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrecipitationVolume { + /// Local-space lower corner `[near, -tan(angle_x)*far, -tan(angle_y)*far]`. + pub origin: [f32; 3], + /// Local-space extent `[far-near, 2*tan(angle_x)*far, 2*tan(angle_y)*far]`. + pub extent: [f32; 3], + /// Reciprocals stored by the native descriptor for containment tests. + pub reciprocal_extent: [f32; 3], +} + +impl PrecipitationVolume { + /// Reconstructs the descriptor constants used by Terrain. + #[must_use] + pub fn native() -> Self { + let width = PRECIPITATION_HALF_ANGLE_X.tan() * PRECIPITATION_FAR; + let height = PRECIPITATION_HALF_ANGLE_Y.tan() * PRECIPITATION_FAR; + let extent = [ + PRECIPITATION_FAR - PRECIPITATION_NEAR, + 2.0 * width, + 2.0 * height, + ]; + Self { + origin: [PRECIPITATION_NEAR, -width, -height], + extent, + reciprocal_extent: extent.map(|value| 1.0 / value), + } + } + + /// Rebuilds the same depth box for a camera projection. The native + /// emitter uses the camera FOV and aspect ratio for this box while the + /// reference descriptor above keeps the original 1.3 by 0.975 angles. + #[must_use] + pub fn for_camera(self, camera: Camera) -> Self { + let near = self.origin[0]; + let far = near + self.extent[0]; + let half_y = (camera.vertical_fov * 0.5).tan() * far; + let half_x = (camera.vertical_fov * 0.5).tan() * camera.aspect_ratio * far; + let extent = [far - near, 2.0 * half_x, 2.0 * half_y]; + Self { + origin: [near, -half_x, -half_y], + extent, + reciprocal_extent: extent.map(|value| 1.0 / value), + } + } + + /// Returns an axis-aligned world-space bound around the camera-local + /// descriptor. The native emitter transforms each sampled local point by + /// the camera basis before handing it to the particle object. + #[must_use] + pub fn bounds(self, camera: Camera) -> PrecipitationBounds { + let corners: [[f32; 3]; 8] = std::array::from_fn(|index| { + let local = [ + self.origin[0] + if index & 1 == 0 { 0.0 } else { self.extent[0] }, + self.origin[1] + if index & 2 == 0 { 0.0 } else { self.extent[1] }, + self.origin[2] + if index & 4 == 0 { 0.0 } else { self.extent[2] }, + ]; + camera.world_from_local(local) + }); + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for corner in corners { + for axis in 0..3 { + min[axis] = min[axis].min(corner[axis]); + max[axis] = max[axis].max(corner[axis]); + } + } + PrecipitationBounds { min, max } + } + + /// Computes the native reference particle count for a weather intensity. + /// The camera-aware form below applies the current/reference volume ratio. + #[must_use] + pub fn particle_count(self, intensity: f32) -> usize { + self.particle_count_for(self, 1.0, intensity) + } + + /// Computes the native count from the current/reference volume ratio and + /// the object's density scalar. The original uses FISTP's nearest-even + /// conversion after multiplying by 1000. + #[must_use] + pub fn particle_count_for(self, current: Self, density: f32, intensity: f32) -> usize { + if !density.is_finite() || !intensity.is_finite() || density <= 0.0 || intensity <= 0.0 { + return 0; + } + let reference_volume = volume_product(self.extent); + let current_volume = volume_product(current.extent); + if !reference_volume.is_finite() || reference_volume <= 0.0 || !current_volume.is_finite() { + return 0; + } + let ratio = (current_volume / reference_volume).clamp(0.0, 1.0); + let value = f64::from(ratio) + * f64::from(density) + * f64::from(intensity) + * f64::from(PARTICLE_COUNT_SCALE); + if !value.is_finite() || value <= 0.0 { + 0 + } else if value >= usize::MAX as f64 { + usize::MAX + } else { + value.round_ties_even() as usize + } + } +} + +impl Default for PrecipitationVolume { + fn default() -> Self { + Self::native() + } +} + +/// World-space bounds for one observer-local precipitation volume. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrecipitationBounds { + /// Inclusive lower corner. + pub min: [f32; 3], + /// Inclusive upper corner. + pub max: [f32; 3], +} + +impl PrecipitationBounds { + /// Checks whether a world-space point lies inside this bound. + #[must_use] + pub fn contains(self, point: [f32; 3]) -> bool { + point + .into_iter() + .zip(self.min.into_iter().zip(self.max)) + .all(|(value, (min, max))| value >= min && value <= max) + } +} + +// CLightning::Update (Terrain.dll!10043870) uses a DWORD game tick. +const LIGHTNING_COOLDOWN_MS: u64 = 6_000; +const LIGHTNING_DELAY_MS: f64 = 60_000.0; +const LIGHTNING_INTENSITY_CAP: f32 = 0.95; +// CLightning's effect descriptor stores a 600-unit vertical quad and +// CLightning::CreateEffect (100439D0) adds half of that length to the +// sampled Z coordinate before creating the visual effect. +const LIGHTNING_NATIVE_WIDTH: f32 = 40.0; +const LIGHTNING_NATIVE_LENGTH: f32 = 600.0; +const LIGHTNING_NATIVE_ORIGIN_OFFSET: f32 = 300.0; +/// Effective CLightning descriptor used by the renderer-facing quad. +pub const LIGHTNING_QUAD_DIMENSIONS: [f32; 3] = [ + LIGHTNING_NATIVE_WIDTH, + LIGHTNING_NATIVE_WIDTH, + LIGHTNING_NATIVE_LENGTH, +]; +const NATIVE_RANDOM_SCALE: f32 = 1.0 / 32_768.0; + +/// The CRT linear congruential generator used by Terrain.dll and Effect.dll. +/// Both modules read and update the same process-wide state, so the +/// environment system intentionally owns one stream and passes it through +/// each emitter in update order. +#[derive(Clone, Copy, Debug)] +struct NativeRng { + state: u32, +} + +impl NativeRng { + fn new(seed: u64) -> Self { + Self { state: seed as u32 } + } + + fn next_u15(&mut self) -> u16 { + self.state = self.state.wrapping_mul(0x343F_D).wrapping_add(0x269E_C); + ((self.state >> 16) & 0x7FFF) as u16 + } + + fn next_unit(&mut self) -> f32 { + f32::from(self.next_u15()) * NATIVE_RANDOM_SCALE + } +} + +/// The two precipitation classes present in the original environment data. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrecipitationKind { + /// Rain drops using the `RAIN_DROP` material. + Rain, + /// Snow flakes using the `SNOWFLAKE` material. + Snow, +} + +/// A sampled weather state supplied by the atmosphere schedule or a caller. +#[derive(Clone, Debug, PartialEq)] +pub struct WeatherState { + /// Whether this weather object is currently running. + pub active: bool, + /// Non-negative emission strength. Values above one are capped for the + /// bounded CPU emitter. + pub intensity: f32, + /// RGBA color passed to the renderer. + pub color: [f32; 4], + /// Resource names attached to the active interval in `sky.ske`. + /// Rain uses the first name as its loop sound; lightning uses the FXID + /// selected by the caller. Keeping these names here avoids silently + /// substituting a global weather asset. + pub resources: Vec, + /// Material row resolved from the current `sky.wea` table. + pub material: Option, +} + +impl WeatherState { + /// Constructs a weather state. + #[must_use] + pub fn new(active: bool, intensity: f32, color: [f32; 4]) -> Self { + Self { + active, + intensity, + color, + resources: Vec::new(), + material: None, + } + } + + /// Constructs a weather state with resource names from the atmosphere + /// key that started its active interval. + #[must_use] + pub fn with_resources(active: bool, intensity: f32, color: [f32; 4], resources: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + active, + intensity, + color, + resources: resources.into_iter().map(Into::into).collect(), + material: None, + } + } + + /// Constructs an inactive weather state. + #[must_use] + pub fn inactive() -> Self { + Self::new(false, 0.0, [0.0; 4]) + } + + fn sanitized(self) -> Self { + let color = self.color.map(|component| { + if component.is_finite() { + component.clamp(0.0, 1.0) + } else { + 0.0 + } + }); + let intensity = if self.intensity.is_finite() { + self.intensity.clamp(0.0, 1.0) + } else { + 0.0 + }; + Self { + active: self.active, + intensity, + color, + resources: self + .resources + .into_iter() + .filter(|resource| !resource.is_empty()) + .collect(), + material: self.material.filter(|material| !material.is_empty()), + } + } +} + +impl Default for WeatherState { + fn default() -> Self { + Self::inactive() + } +} + +/// Weather input for one environment update. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WeatherInput { + /// Rain state. + pub rain: WeatherState, + /// Snow state. + pub snow: WeatherState, + /// Lightning state. + pub lightning: WeatherState, +} + +impl WeatherInput { + /// Constructs a clear-weather input. + #[must_use] + pub fn clear() -> Self { + Self { + rain: WeatherState::inactive(), + snow: WeatherState::inactive(), + lightning: WeatherState::inactive(), + } + } + + /// Converts a sampled `sky.ske` frame into environment input. + #[must_use] + pub fn from_atmosphere(frame: &crate::atmosphere::AtmosphereFrame) -> Self { + Self { + rain: from_atmosphere_weather(&frame.rain), + snow: from_atmosphere_weather(&frame.snow), + lightning: from_atmosphere_weather(&frame.lightning), + } + } + + /// Converts a sampled atmosphere frame and the current `sky.wea` table + /// into environment input. Rows seven and eight are the native snow and + /// rain material slots; the table is resolved at runtime so missions can + /// replace their names. + #[must_use] + pub fn from_atmosphere_with_materials( + frame: &crate::atmosphere::AtmosphereFrame, + materials: &SkyMaterials, + ) -> Self { + let mut input = Self::from_atmosphere(frame); + input.rain.material = materials.material_name(8).map(str::to_owned); + input.snow.material = materials.material_name(7).map(str::to_owned); + input + } + + fn sanitized(self) -> Self { + Self { + rain: self.rain.sanitized(), + snow: self.snow.sanitized(), + lightning: self.lightning.sanitized(), + } + } +} + +/// Camera snapshot used to keep finite weather geometry around the observer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Camera { + /// World-space camera position. The renderer uses +Z as up. + pub position: [f32; 3], + /// Unit world-space direction of camera-local depth. + pub forward: [f32; 3], + /// Unit world-space direction of camera-local horizontal offset. + pub right: [f32; 3], + /// Unit world-space direction of camera-local vertical offset. + pub up: [f32; 3], + /// Vertical camera field of view in radians. + pub vertical_fov: f32, + /// Horizontal-to-vertical projection aspect ratio. + pub aspect_ratio: f32, + /// Viewport width and height in pixels used by screen-space particle quads. + pub viewport: [f32; 2], +} + +impl Camera { + /// Creates a camera snapshot. + #[must_use] + pub const fn new(position: [f32; 3]) -> Self { + Self { + position, + forward: [1.0, 0.0, 0.0], + right: [0.0, 1.0, 0.0], + up: [0.0, 0.0, 1.0], + vertical_fov: 0.975, + // tan(0.65) / tan(0.4875), the original reference box aspect. + aspect_ratio: 1.4338578, + viewport: [640.0, 480.0], + } + } + + /// Creates a camera snapshot with the orientation used by the native + /// camera-local precipitation transform. + #[must_use] + pub const fn with_basis( + position: [f32; 3], + forward: [f32; 3], + right: [f32; 3], + up: [f32; 3], + ) -> Self { + Self { + position, + forward, + right, + up, + vertical_fov: 0.975, + aspect_ratio: 1.4338578, + viewport: [640.0, 480.0], + } + } + + /// Creates a camera snapshot with orientation and projection data. + #[must_use] + pub const fn with_projection( + position: [f32; 3], + forward: [f32; 3], + right: [f32; 3], + up: [f32; 3], + vertical_fov: f32, + aspect_ratio: f32, + ) -> Self { + Self { + position, + forward, + right, + up, + vertical_fov, + aspect_ratio, + viewport: [640.0, 480.0], + } + } + + /// Sets the pixel viewport used by the projected particle geometry. + #[must_use] + pub const fn with_viewport(self, viewport: [f32; 2]) -> Self { + Self { viewport, ..self } + } + + /// Returns the horizontal field of view represented by this projection. + #[must_use] + pub fn horizontal_fov(self) -> f32 { + 2.0 * ((self.vertical_fov * 0.5).tan() * self.aspect_ratio).atan() + } + + /// Returns Terrain's precipitation size query: viewport width divided by + /// the camera's horizontal FOV in radians. + #[must_use] + pub fn precipitation_size_scalar(self) -> f32 { + let fov = self.horizontal_fov(); + if fov.is_finite() && fov > 0.0 { + self.viewport[0] / fov + } else { + 0.0 + } + } + + /// Transforms a camera-local point into world space. + #[must_use] + pub fn world_from_local(self, local: [f32; 3]) -> [f32; 3] { + [ + self.position[0] + + self.forward[0] * local[0] + + self.right[0] * local[1] + + self.up[0] * local[2], + self.position[1] + + self.forward[1] * local[0] + + self.right[1] * local[1] + + self.up[1] * local[2], + self.position[2] + + self.forward[2] * local[0] + + self.right[2] * local[1] + + self.up[2] * local[2], + ] + } + + /// Transforms a camera-local vector into world space. + #[must_use] + pub fn world_vector(self, local: [f32; 3]) -> [f32; 3] { + [ + self.forward[0] * local[0] + self.right[0] * local[1] + self.up[0] * local[2], + self.forward[1] * local[0] + self.right[1] * local[1] + self.up[1] * local[2], + self.forward[2] * local[0] + self.right[2] * local[1] + self.up[2] * local[2], + ] + } + + /// Transforms a world-space point into camera-local coordinates. + #[must_use] + pub fn local_from_world(self, world: [f32; 3]) -> [f32; 3] { + let delta = [ + world[0] - self.position[0], + world[1] - self.position[1], + world[2] - self.position[2], + ]; + [ + dot3(self.forward, delta), + dot3(self.right, delta), + dot3(self.up, delta), + ] + } + + fn sanitized(self) -> Self { + let mut camera = self; + camera.position = camera.position.map(|component| { + if component.is_finite() { + component + } else { + 0.0 + } + }); + camera.forward = finite_unit(camera.forward).unwrap_or([1.0, 0.0, 0.0]); + camera.right = finite_unit(camera.right).unwrap_or([0.0, 1.0, 0.0]); + camera.up = finite_unit(camera.up).unwrap_or([0.0, 0.0, 1.0]); + if !camera.vertical_fov.is_finite() || camera.vertical_fov <= 0.0 { + camera.vertical_fov = 0.975; + } + camera.vertical_fov = camera.vertical_fov.min(std::f32::consts::PI - 0.001); + if !camera.aspect_ratio.is_finite() || camera.aspect_ratio <= 0.0 { + camera.aspect_ratio = 1.4338578; + } + for value in &mut camera.viewport { + if !value.is_finite() || *value <= 0.0 { + *value = 1.0; + } + } + camera + } +} + +impl Default for Camera { + fn default() -> Self { + Self::new([0.0; 3]) + } +} + +/// World-space range used by the native lightning position sampler. +/// +/// `CLightning::Update` samples X from `[x_min, x_max]`, Y from +/// `[y_min, y_max]`, and copies `z` unchanged. The atmosphere manager owns +/// these values in the original engine, so the default environment system +/// uses the camera position as a zero-size range until a caller supplies the +/// current bounds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LightningBounds { + /// Lower and upper X bounds in world coordinates. + pub x: [f32; 2], + /// Lower and upper Y bounds in world coordinates. + pub y: [f32; 2], + /// Z coordinate copied to the effect origin. + pub z: f32, +} + +impl LightningBounds { + /// Creates a world-space lightning sampling range. + #[must_use] + pub const fn new(x_min: f32, x_max: f32, y_min: f32, y_max: f32, z: f32) -> Self { + Self { + x: [x_min, x_max], + y: [y_min, y_max], + z, + } + } + + fn sanitized(self) -> Option { + if self + .x + .into_iter() + .chain(self.y) + .chain([self.z]) + .all(f32::is_finite) + { + Some(self) + } else { + None + } + } +} + +/// Native point-light state produced by an Effect opcode 1 command. +/// +/// 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 +/// attenuation coefficients. These values do not describe lightning mesh +/// geometry or texture coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointLightFrame { + /// Opcode 1 type word. `env_lightning` uses type 7. + pub effect_type: u32, + /// Effect age supplied to the native evaluator, in seconds. + pub elapsed_seconds: f32, + /// Interpolation parameter between the opcode 1 start/end times. + pub normalized_time: f32, + /// FXID lifetime in seconds. + pub duration_seconds: f32, + /// World-space LightManager position. + pub position: [f32; 3], + /// World-space direction submitted alongside the point-light record. + pub direction: [f32; 3], + /// HDR RGBA color submitted to LightManager slot +0C. + pub color: [f32; 4], + /// Light range submitted to LightManager slot +10. + 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. + pub effect_factor: f32, + /// Whether the opcode 1 command is active at `elapsed_seconds`. + pub active: bool, +} + +/// Native quad produced by the opcode 3 lightning visual. +/// +/// CLightning creates this effect after moving the sampled position 300 +/// units upward. The owner descriptor is `[40, 40, 600]`; opcode 1 does not +/// supply the quad vertices or texture coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LightningQuadFrame { + /// Four world-space vertices in native perimeter order: bottom plus + /// horizontal, bottom minus horizontal, top minus horizontal, top plus + /// horizontal. + pub vertices: [[f32; 3]; 4], + /// Native UVs in the same order as `vertices`. + pub uv: [[f32; 2]; 4], + /// CLightning's width, height, and effective streak length. + pub dimensions: [f32; 3], + /// Opcode 3 animated local position from the native start/delta channels. + pub local_offset: [f32; 3], + /// Opcode 3 animated local scale from the native start/end channels. + pub local_scale: [f32; 3], + /// Native opcode 3 alpha channel. `env_lightning` fades from one to zero + /// over its active interval. + pub opacity: f32, + /// Effect age supplied to the native opcode 3 evaluator, in seconds. + pub elapsed_seconds: f32, + /// Interpolation parameter between the opcode 3 start/end times. + pub normalized_time: f32, + /// FXID lifetime in seconds. + pub duration_seconds: f32, + /// Whether the opcode 3 command is active at `elapsed_seconds`. + pub active: bool, +} + +/// Screen-space quad data produced by the native rain/snow geometry paths. +/// `head`, `tail`, and `corners` are normalized device coordinates. The native +/// size remains in viewport pixels so a renderer can apply the same query +/// without mixing pixel and NDC units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScreenBillboard { + /// World-space head point. + pub world_head: [f32; 3], + /// World-space tail point. + pub world_tail: [f32; 3], + /// Projected head point in NDC. + pub head: [f32; 2], + /// Projected tail point in NDC. Snow uses the same point as `head`. + pub tail: [f32; 2], + /// Four NDC corners in tail-left, tail-right, head-left, head-right order. + pub corners: [[f32; 2]; 4], + /// Texture coordinates for the four corners. + pub uv: [[f32; 2]; 4], + /// Native half-size argument in viewport pixels. + pub half_size: f32, + /// Camera-local depth of the projected head. + pub head_depth: f32, + /// Camera-local depth of the projected tail. + pub tail_depth: f32, +} + +/// Geometry and light output decoded from the FXID. +#[derive(Clone, Debug, PartialEq)] +pub struct LightningBolt { + /// Material archive and name resolved from opcode 3. + pub material: EffectResource, + /// World-space sampled position supplied to CLightning. + pub position: [f32; 3], + /// Opcode 3 world-space quad and material state. + pub quad: LightningQuadFrame, + /// Current weather intensity used by the event scheduler. + pub intensity: f32, + /// Effect age in seconds from its FXID start time. + pub age_seconds: f32, + /// FXID lifetime in seconds used by the native evaluator. + pub duration_seconds: f32, +} + +/// Native environment primitives consumed by the renderer. +#[derive(Clone, Debug, PartialEq)] +pub enum EnvironmentPrimitive { + /// A rain drop or snow flake billboard instance. + Particle { + /// Precipitation class. + kind: PrecipitationKind, + /// World-space position. + position: [f32; 3], + /// World-space velocity. + velocity: [f32; 3], + /// Native size argument after applying the rain/snow size factor. + size: f32, + /// RGBA color. + color: [f32; 4], + /// Material lookup name from `sky.wea`. + material: String, + /// Native projected quad geometry in viewport pixels. + screen: ScreenBillboard, + }, + /// A lightning bolt with opcode 3 quad data. + Lightning(LightningBolt), +} + +/// Action applied to a sound resource by the caller's audio backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SoundAction { + /// Start a loop at the event's volume. + StartLoop, + /// Change the volume of an already-running loop. + SetLoopVolume, + /// Stop a previously started loop. + StopLoop, + /// Play one sample once. + OneShot, +} + +/// One sound event generated by the environment simulation. +#[derive(Clone, Debug, PartialEq)] +pub struct SoundEvent { + /// Archive to resolve through the resource repository. Empty means the + /// audio owner should use its mission-configured weather library. + pub archive: String, + /// Entry name in `archive`. + pub name: String, + /// World-space emitter position. + pub position: [f32; 3], + /// Linear volume multiplier. + pub volume: f32, + /// Native minimum distance in world units. A non-positive value disables + /// the native range clamp for callers such as atmosphere loops. + pub min_distance: f32, + /// Native maximum distance in world units. A non-positive or non-finite + /// value disables the native range clamp. + pub max_distance: f32, + /// Native playback-frequency ratio evaluated at event creation. + pub frequency_ratio: f32, + /// Playback action. + pub action: SoundAction, +} + +/// Native opcode-2 sound parameters evaluated for one effect age. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SoundParameters { + /// DirectSound minimum distance in world units. + pub min_distance: f32, + /// DirectSound maximum distance in world units. + pub max_distance: f32, + /// Playback frequency ratio. The shipped thunder effect keeps this at 1. + pub frequency_ratio: f32, +} + +impl Default for SoundParameters { + fn default() -> Self { + Self { + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + } + } +} + +/// All visible and audible environment output for one frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct EnvironmentFrame { + /// Renderer primitives in stable emitter order. + pub primitives: Vec, + /// Dynamic point lights emitted by native Effect opcode 1 commands. + pub point_lights: Vec, + /// Sound transitions and one-shot events. + pub sounds: Vec, +} + +/// A validated resource name from the `env_lightning` FXID. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectResource { + /// Archive name. + pub archive: String, + /// Entry name. + pub name: String, +} + +/// The real visual and sound contracts extracted from `env_lightning`. +#[derive(Clone, Debug, PartialEq)] +pub struct LightningEffect { + /// FXID header lifetime in seconds. + pub duration_seconds: f32, + /// Visual material resource from the opcode 3 body. + pub visual: EffectResource, + /// Complete opcode-3 body used by the native quad evaluator. + pub visual_body: Arc<[u8]>, + /// Complete opcode-1 body for the native Effect evaluator. + pub opcode1_body: Arc<[u8]>, + /// Command index containing those visual parameters. + pub opcode1_command_index: usize, + /// Sound resource from the opcode 2 body. + pub sound: EffectResource, + /// Complete opcode-2 body used to evaluate the native thunder gain and + /// active interval at the event origin. + pub sound_body: Arc<[u8]>, + /// Command index containing the visual material. + pub visual_command_index: usize, + /// Command index containing the sound resource. + pub sound_command_index: usize, +} + +impl LightningEffect { + /// Extracts the visual, point-light, and sound contracts from a decoded + /// lightning FXID. + /// + /// The generic FXID parser intentionally preserves command bodies without + /// claiming field semantics. Opcode 3 has a material reference at body + /// offset 132, opcode 1 carries the LightManager record, and opcode 2 + /// has a sound reference at body offsets 80 and 112. + /// + /// # Errors + /// + /// Returns an error if the document does not contain those commands or + /// contains truncated command bodies. + pub fn from_fxid(document: &FxDocument) -> Result { + let duration_seconds = document.header().duration_seconds; + if !duration_seconds.is_finite() || duration_seconds <= 0.0 { + return Err(EnvironmentError::InvalidDuration { duration_seconds }); + } + + let mut visual = None; + let mut opcode1 = None; + let mut sound = None; + for (index, command) in document.commands().iter().enumerate() { + match command.opcode { + FxOpcode::Op3 if visual.is_none() => { + let resource = read_resource(index, command.opcode, &command.raw_body, 132)?; + validate_opcode3_body(index, &command.raw_body)?; + visual = Some((index, resource, Arc::clone(&command.raw_body))); + } + FxOpcode::Op1 if opcode1.is_none() => { + validate_opcode1_body(index, &command.raw_body)?; + opcode1 = Some((index, Arc::clone(&command.raw_body))); + } + FxOpcode::Op2 if sound.is_none() => { + validate_opcode2_body(index, &command.raw_body)?; + let resource = read_resource(index, command.opcode, &command.raw_body, 80)?; + let name = read_name(&command.raw_body, index, command.opcode, 112)?; + sound = Some(( + index, + EffectResource { + archive: resource.archive, + name, + }, + Arc::clone(&command.raw_body), + )); + } + _ => {} + } + } + + let Some((visual_command_index, visual, visual_body)) = visual else { + return Err(EnvironmentError::MissingCommand { + opcode: FxOpcode::Op3, + }); + }; + let Some((opcode1_command_index, opcode1_body)) = opcode1 else { + return Err(EnvironmentError::MissingCommand { + opcode: FxOpcode::Op1, + }); + }; + let Some((sound_command_index, sound, sound_body)) = sound else { + return Err(EnvironmentError::MissingCommand { + opcode: FxOpcode::Op2, + }); + }; + Ok(Self { + duration_seconds, + visual, + visual_body, + opcode1_body, + opcode1_command_index, + sound, + sound_body, + visual_command_index, + sound_command_index, + }) + } + + /// Evaluates the native opcode-2 distance and playback parameters at an + /// effect age. The FX command stores minimum/maximum distance at body + /// offsets `0x3c`/`0x40` and interpolates the frequency ratio from + /// `0x44` to `0x48`; body offset `0x4c` remains in the preserved raw + /// command because its backend meaning is not established. + #[must_use] + pub fn sound_parameters(&self, elapsed_seconds: f32) -> Option { + if !elapsed_seconds.is_finite() + || !self.duration_seconds.is_finite() + || self.duration_seconds <= 0.0 + { + return None; + } + let (normalized_time, _) = active_interval( + self.sound_body.as_ref(), + elapsed_seconds, + self.duration_seconds, + )?; + let min_distance = read_f32(self.sound_body.as_ref(), 60)?; + let max_distance = read_f32(self.sound_body.as_ref(), 64)?; + let frequency_ratio = lerp( + read_f32(self.sound_body.as_ref(), 68)?, + read_f32(self.sound_body.as_ref(), 72)?, + normalized_time, + ); + if !min_distance.is_finite() || !max_distance.is_finite() || !frequency_ratio.is_finite() { + return None; + } + Some(SoundParameters { + min_distance, + max_distance, + frequency_ratio, + }) + } + + /// Evaluates the native opcode 1 point-light command at `elapsed_seconds`. + #[must_use] + pub fn evaluate_point_light( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + ) -> Option { + self.evaluate_point_light_with_basis(elapsed_seconds, origin, identity_basis()) + } + + /// Evaluates opcode 1 and applies a parent basis to its LightManager + /// position and direction. `basis` stores the world-space vectors + /// corresponding to local +X, +Y, and +Z. + #[must_use] + pub fn evaluate_point_light_with_basis( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + ) -> Option { + if !self.valid_effect_time(elapsed_seconds, origin, basis) { + return None; + } + + let body = self.opcode1_body.as_ref(); + let effect_type = read_u32(body, 0)?; + let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?; + let local_position = lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time); + let local_direction = lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time); + let color = lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time); + let range = lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time); + let attenuation = [ + read_f32(body, 120)?, + read_f32(body, 124)?, + read_f32(body, 128)?, + ]; + let effect_factor = read_f32(body, 116)?; + let direction = finite_unit(transform3(basis, local_direction)) + .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0])); + + Some(PointLightFrame { + effect_type, + elapsed_seconds, + normalized_time, + duration_seconds: self.duration_seconds, + position: add3(origin, transform3(basis, local_position)), + direction, + color: color.map(sanitize_float), + range: sanitize_float(range).max(0.0), + attenuation: attenuation.map(sanitize_float), + effect_factor: sanitize_float(effect_factor), + active: true, + }) + } + + /// Evaluates the native opcode 3 visual quad at `elapsed_seconds`. + /// + /// The native owner supplies the effective descriptor `[40, 40, 600]` + /// and the effect origin after adding 300 units to the sampled Z. Opcode + /// 3 contributes the material modifiers and active interval; its fixed + /// full-texture UVs are independent of opcode 1. + #[must_use] + pub fn evaluate_quad( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + camera: Camera, + ) -> Option { + self.evaluate_quad_with_mirror(elapsed_seconds, origin, camera, false) + } + + /// Evaluates the native opcode 3 visual quad with the per-bolt mirrored + /// texture orientation selected by `CLightning::CreateEffect`. + #[must_use] + pub fn evaluate_quad_with_mirror( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + camera: Camera, + mirrored: bool, + ) -> Option { + let camera = camera.sanitized(); + if !self.valid_effect_time(elapsed_seconds, origin, identity_basis()) { + return None; + } + let body = self.visual_body.as_ref(); + let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?; + let local_offset = evaluate_position(body, normalized_time)?; + let local_scale = evaluate_scale(body, normalized_time)?; + // The owner matrix is diagonal. The mirrored path negates its local + // Y basis; that sign participates in both the inverse camera + // transform and the forward transform of the billboard frame. + // Effect.dll first computes `world_center = M_base * local_offset`, + // then builds `M_base * Diag(local_scale)`. It never applies the + // animated scale to the inverse camera vector. + let base_diag = lightning_parent_diagonal(mirrored); + let mut geometry_diag = base_diag.map(|value| value); + for (axis, scale) in geometry_diag.iter_mut().zip(local_scale) { + *axis *= scale; + } + let dimensions = [ + LIGHTNING_QUAD_DIMENSIONS[0] * local_scale[0], + LIGHTNING_QUAD_DIMENSIONS[1] * local_scale[1], + LIGHTNING_QUAD_DIMENSIONS[2] * local_scale[2], + ]; + let center = add3( + origin, + [ + base_diag[0] * local_offset[0], + base_diag[1] * local_offset[1], + base_diag[2] * local_offset[2], + ], + ); + + // The shipped env_lightning command is type 1. Its native receiver + // builds a local frame with columns `(axis, U, W)`, where + // `U = normalize(V x axis)` and `W = axis x U`. The canonical quad + // uses the first two columns; the third column supplies the + // camera-facing normal to the native receiver. `V` is intentionally + // not normalized before the exact parallel-view test. + let axis = [0.0, 0.0, 1.0]; + let camera_local = [ + (camera.position[0] - origin[0]) / base_diag[0], + (camera.position[1] - origin[1]) / base_diag[1], + (camera.position[2] - origin[2]) / base_diag[2], + ]; + let raw_view = sub3(camera_local, local_offset); + // Native type 1 compares the raw dot product against exactly +/-1. + // A near-parallel view therefore follows the cross-product path. + if dot3(raw_view, axis).abs() == 1.0 { + return None; + } + let horizontal_local = normalized_or_fallback(cross3(raw_view, axis), [1.0, 0.0, 0.0]); + let _normal_local = cross3(axis, horizontal_local); + let horizontal = transform_diagonal(geometry_diag, horizontal_local); + let vertical = transform_diagonal(geometry_diag, axis); + let bottom = sub3(center, scale3(vertical, 0.5)); + let top = add3(center, scale3(vertical, 0.5)); + let positive_horizontal = scale3(horizontal, 0.5); + let negative_horizontal = scale3(horizontal, -0.5); + Some(LightningQuadFrame { + vertices: [ + add3(bottom, positive_horizontal), + add3(bottom, negative_horizontal), + add3(top, negative_horizontal), + add3(top, positive_horizontal), + ], + uv: [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]], + dimensions, + local_offset: local_offset.map(sanitize_float), + local_scale: local_scale.map(sanitize_float), + opacity: sanitize_float(1.0 - normalized_time).clamp(0.0, 1.0), + elapsed_seconds, + normalized_time, + duration_seconds: self.duration_seconds, + active: true, + }) + } + + fn valid_effect_time( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + ) -> bool { + elapsed_seconds.is_finite() + && self.duration_seconds.is_finite() + && self.duration_seconds > 0.0 + && elapsed_seconds >= 0.0 + && elapsed_seconds <= self.duration_seconds + && origin.iter().all(|value| value.is_finite()) + && basis.iter().flatten().all(|value| value.is_finite()) + } +} + +/// Decodes and validates the original `env_lightning` FXID payload. +pub fn decode_env_lightning_fxid(bytes: Arc<[u8]>) -> Result { + let document = crate::decode_fxid(bytes)?; + LightningEffect::from_fxid(&document) +} + +/// Environment simulation failure. +#[derive(Debug)] +pub enum EnvironmentError { + /// Generic FXID framing error. + Fx(FxError), + /// FXID lifetime is unusable for an active effect. + InvalidDuration { + /// Value from the FXID header. + duration_seconds: f32, + }, + /// Required command is absent. + MissingCommand { + /// Required opcode. + opcode: FxOpcode, + }, + /// A command body is too short for the established layout. + InvalidCommandLayout { + /// Command index. + command_index: usize, + /// Command opcode. + opcode: FxOpcode, + /// Required body length. + required: usize, + /// Actual body length. + actual: usize, + }, + /// A known command points at an unexpected resource. + UnexpectedResource { + /// Command index. + command_index: usize, + /// Resource field being checked. + part: &'static str, + /// Decoded archive name. + archive: String, + /// Decoded resource name. + name: String, + }, + /// Opcode 1 contains a non-finite visual parameter. + NonFiniteVisualValue { + /// Command index. + command_index: usize, + /// Value index in the decoded prefix. + index: usize, + /// Value read from the command body. + value: f32, + }, + /// Opcode 2 contains a non-finite sound parameter. + NonFiniteSoundValue { + /// Command index. + command_index: usize, + /// Value index in the numeric sound prefix. + index: usize, + /// Value read from the command body. + value: f32, + }, +} + +impl From for EnvironmentError { + fn from(value: FxError) -> Self { + Self::Fx(value) + } +} + +impl std::fmt::Display for EnvironmentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Fx(error) => write!(f, "{error}"), + Self::InvalidDuration { duration_seconds } => { + write!(f, "invalid env_lightning FXID duration {duration_seconds}") + } + Self::MissingCommand { opcode } => { + write!(f, "env_lightning is missing required {opcode:?} command") + } + Self::InvalidCommandLayout { + command_index, + opcode, + required, + actual, + } => write!( + f, + "env_lightning command {command_index} ({opcode:?}) needs {required} body bytes, found {actual}" + ), + Self::UnexpectedResource { + command_index, + part, + archive, + name, + } => write!( + f, + "unexpected env_lightning {part} at command {command_index}: {archive}/{name}" + ), + Self::NonFiniteVisualValue { + command_index, + index, + value, + } => write!( + f, + "env_lightning opcode 1 command {command_index} has non-finite visual value {index}: {value}" + ), + Self::NonFiniteSoundValue { + command_index, + index, + value, + } => write!( + f, + "env_lightning opcode 2 command {command_index} has non-finite sound value {index}: {value}" + ), + } + } +} + +impl std::error::Error for EnvironmentError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Fx(error) => Some(error), + Self::InvalidDuration { .. } + | Self::MissingCommand { .. } + | Self::InvalidCommandLayout { .. } + | Self::UnexpectedResource { .. } + | Self::NonFiniteVisualValue { .. } + | Self::NonFiniteSoundValue { .. } => None, + } + } +} + +/// Stateful CPU environment emitter. +#[derive(Clone, Debug)] +pub struct EnvironmentSystem { + time_seconds: f32, + time_millis: u64, + time_millis_fraction: f64, + /// Terrain and Effect share the CRT `rand` state. Keep one stream for + /// particle respawns, lightning scheduling, and lightning placement so + /// the native call order remains observable across subsystems. + rng: NativeRng, + rain: PrecipitationEmitter, + snow: PrecipitationEmitter, + materials: SkyMaterials, + lightning_effect: Option, + lightning_bounds: Option, + lightning: LightningState, + rain_loop_active: bool, + rain_loop_resource: Option, +} + +impl EnvironmentSystem { + /// Creates a deterministic environment system. + #[must_use] + pub fn new(seed: u64) -> Self { + Self { + time_seconds: 0.0, + time_millis: 0, + time_millis_fraction: 0.0, + rng: NativeRng::new(seed), + rain: PrecipitationEmitter::new(PrecipitationKind::Rain, PrecipitationVolume::native()), + snow: PrecipitationEmitter::new(PrecipitationKind::Snow, PrecipitationVolume::native()), + materials: SkyMaterials::default(), + lightning_effect: None, + lightning_bounds: None, + lightning: LightningState::new(), + rain_loop_active: false, + rain_loop_resource: None, + } + } + + /// Installs the mission's `sky.wea` material table. The rain and snow + /// emitters resolve rows eight and seven from this table on every frame. + pub fn set_sky_materials(&mut self, materials: SkyMaterials) { + self.materials = materials; + } + + /// Returns the active precipitation descriptor. + #[must_use] + pub fn precipitation_volume(&self) -> PrecipitationVolume { + self.rain.volume + } + + /// Installs a validated `env_lightning` effect contract. + pub fn set_lightning_effect(&mut self, effect: LightningEffect) { + self.lightning_effect = effect + .duration_seconds + .is_finite() + .then_some(effect) + .filter(|effect| effect.duration_seconds > 0.0); + self.lightning.clear_visual(); + } + + /// Removes the installed lightning effect. + pub fn clear_lightning_effect(&mut self) { + self.lightning_effect = None; + self.lightning.clear_visual(); + } + + /// Supplies the current world-space bounds used by the native lightning + /// sampler. Passing non-finite bounds makes the system fall back to the + /// camera position for subsequent flashes. + pub fn set_lightning_bounds(&mut self, bounds: LightningBounds) { + self.lightning_bounds = bounds.sanitized(); + } + + /// Removes explicit lightning bounds. Subsequent effects use the camera + /// position as a zero-size range. + pub fn clear_lightning_bounds(&mut self) { + self.lightning_bounds = None; + } + + /// Current simulation time in seconds. + #[must_use] + pub fn time_seconds(&self) -> f32 { + self.time_seconds + } + + /// Current simulation time in whole native milliseconds. + #[must_use] + pub fn time_millis(&self) -> u64 { + self.time_millis + } + + /// Advances precipitation and lightning and returns renderer/audio output. + /// + /// `dt_seconds` is sanitized to a finite non-negative value. Particle + /// positions are retained between frames and wrapped in the camera-local + /// descriptor using the native floor-based wrap operation, so a renderer + /// can consume this frame as a direct list of instances. + #[must_use] + pub fn update( + &mut self, + dt_seconds: f32, + weather: WeatherInput, + camera: Camera, + ) -> EnvironmentFrame { + let dt_seconds = if dt_seconds.is_finite() { + dt_seconds.max(0.0) + } else { + 0.0 + }; + let weather = weather.sanitized(); + let camera = camera.sanitized(); + self.time_seconds = self.time_seconds.max(0.0) + dt_seconds; + self.advance_millis(dt_seconds); + + let mut frame = EnvironmentFrame::default(); + self.rain.update( + dt_seconds, + &weather.rain, + camera, + self.materials.material_name(8), + &mut self.rng, + &mut frame.primitives, + ); + self.snow.update( + dt_seconds, + &weather.snow, + camera, + self.materials.material_name(7), + &mut self.rng, + &mut frame.primitives, + ); + + let rain_resource = weather.rain.resources.first().cloned(); + if weather.rain.active && self.rain_loop_active && rain_resource != self.rain_loop_resource + { + if let Some(name) = self.rain_loop_resource.clone() { + let (archive, name) = split_resource(&name); + frame.sounds.push(SoundEvent { + archive, + name, + position: camera.position, + volume: 0.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::StopLoop, + }); + } + } + if weather.rain.active != self.rain_loop_active + || (weather.rain.active && rain_resource != self.rain_loop_resource) + { + let (resource, action) = if weather.rain.active { + (rain_resource.clone(), SoundAction::StartLoop) + } else { + (self.rain_loop_resource.clone(), SoundAction::StopLoop) + }; + if let Some(resource) = resource { + let (archive, name) = split_resource(&resource); + frame.sounds.push(SoundEvent { + archive, + name, + position: camera.position, + volume: weather.rain.intensity, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action, + }); + } + } else if weather.rain.active { + // Atmosphere intensity is continuously interpolated between + // keys. The native rain object keeps its loop alive while that + // value changes, so the audio owner must receive the current + // volume even when the lifecycle state is unchanged. + if let Some(name) = rain_resource.clone() { + let (archive, name) = split_resource(&name); + frame.sounds.push(SoundEvent { + archive, + name, + position: camera.position, + volume: weather.rain.intensity, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::SetLoopVolume, + }); + } + } + self.rain_loop_active = weather.rain.active; + self.rain_loop_resource = weather.rain.active.then_some(rain_resource).flatten(); + + self.lightning.update( + self.time_millis, + weather.lightning, + camera, + self.lightning_effect.as_ref(), + self.lightning_bounds, + &mut self.rng, + &mut frame.primitives, + &mut frame.point_lights, + &mut frame.sounds, + ); + + frame + } + + /// Advances from an `AtmosphereFrame` sampled by the `sky.ske` parser. + #[must_use] + pub fn update_atmosphere( + &mut self, + dt_seconds: f32, + frame: &crate::atmosphere::AtmosphereFrame, + camera: Camera, + ) -> EnvironmentFrame { + self.update(dt_seconds, WeatherInput::from_atmosphere(frame), camera) + } + + /// Advances from a sampled `sky.ske` frame and its mission `sky.wea` + /// material table. + #[must_use] + pub fn update_atmosphere_with_materials( + &mut self, + dt_seconds: f32, + frame: &crate::atmosphere::AtmosphereFrame, + materials: &SkyMaterials, + camera: Camera, + ) -> EnvironmentFrame { + self.set_sky_materials(materials.clone()); + self.update( + dt_seconds, + WeatherInput::from_atmosphere_with_materials(frame, materials), + camera, + ) + } + + fn advance_millis(&mut self, dt_seconds: f32) { + let millis = self.time_millis_fraction + f64::from(dt_seconds) * 1_000.0; + if !millis.is_finite() || millis <= 0.0 { + return; + } + let whole = millis.floor(); + self.time_millis = self + .time_millis + .saturating_add(if whole >= u64::MAX as f64 { + u64::MAX + } else { + whole as u64 + }); + self.time_millis_fraction = millis - whole; + } +} + +#[derive(Clone, Debug)] +struct PrecipitationEmitter { + kind: PrecipitationKind, + volume: PrecipitationVolume, + particles: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct ParticleState { + /// World-space head position. Native integration advances this directly + /// before converting it to camera-local coordinates for wrapping. + position: [f32; 3], + /// Previous world-space head position, used as the rain tail. + previous_position: [f32; 3], + respawns: u64, +} + +impl PrecipitationEmitter { + fn new(kind: PrecipitationKind, volume: PrecipitationVolume) -> Self { + Self { + kind, + volume, + particles: Vec::new(), + } + } + + fn update( + &mut self, + dt_seconds: f32, + state: &WeatherState, + camera: Camera, + default_material: Option<&str>, + rng: &mut NativeRng, + out: &mut Vec, + ) { + if !state.active || state.intensity <= 0.0 { + return; + } + let Some(material) = state + .material + .as_deref() + .or(default_material) + .filter(|material| !material.is_empty()) + else { + return; + }; + let current_volume = self.volume.for_camera(camera); + let desired = self + .volume + .particle_count_for(current_volume, 1.0, state.intensity); + self.particles.truncate(desired); + while self.particles.len() < desired { + let index = self.particles.len(); + self.particles.push(ParticleState { + position: [0.0; 3], + previous_position: [0.0; 3], + respawns: 0, + }); + self.respawn(index, camera, current_volume, rng); + } + + let velocity = self.velocity(); + let kind = self.kind; + let size_factor = match kind { + PrecipitationKind::Rain => RAIN_SIZE_FACTOR, + PrecipitationKind::Snow => SNOW_SIZE_FACTOR, + }; + let size = camera.precipitation_size_scalar() * size_factor; + for index in 0..desired { + let particle = &mut self.particles[index]; + particle.previous_position = particle.position; + particle.position[0] += velocity[0] * dt_seconds; + particle.position[1] += velocity[1] * dt_seconds; + particle.position[2] += velocity[2] * dt_seconds; + let local = camera.local_from_world(particle.position); + let mut wrapped_local = local; + let mut wrapped = false; + for axis in 0..3 { + let offset = local[axis] - current_volume.origin[axis]; + let wrapped_offset = wrap_local(offset, current_volume.extent[axis]); + wrapped_local[axis] = current_volume.origin[axis] + wrapped_offset; + wrapped |= (wrapped_offset - offset).abs() > f32::EPSILON; + } + if wrapped { + // A wrapped particle has no continuous temporal tail across + // the opposite faces of the volume. + particle.position = camera.world_from_local(wrapped_local); + particle.previous_position = particle.position; + } + let position = particle.position; + let tail = particle.previous_position; + out.push(EnvironmentPrimitive::Particle { + kind, + position, + velocity, + size, + color: state.color, + material: material.to_owned(), + screen: screen_billboard(kind, camera, position, tail, size, index), + }); + } + } + + fn respawn( + &mut self, + index: usize, + camera: Camera, + volume: PrecipitationVolume, + rng: &mut NativeRng, + ) { + let particle = &mut self.particles[index]; + let local = [ + rng.next_unit() * volume.extent[0], + rng.next_unit() * volume.extent[1], + rng.next_unit() * volume.extent[2], + ]; + particle.position = camera.world_from_local(add3(volume.origin, local)); + particle.previous_position = particle.position; + particle.respawns = particle.respawns.wrapping_add(1); + } + + fn velocity(&self) -> [f32; 3] { + match self.kind { + PrecipitationKind::Rain => RAIN_VELOCITY, + PrecipitationKind::Snow => SNOW_VELOCITY, + } + } +} + +#[derive(Clone, Debug)] +struct LightningState { + phase: u8, + last_time_ms: u64, + deadline_ms: u64, + active: bool, + visual: Option, +} + +#[derive(Clone, Debug)] +struct LightningRuntime { + effect: LightningEffect, + position: [f32; 3], + intensity: f32, + mirrored: bool, + started_ms: u64, +} + +impl LightningState { + fn new() -> Self { + Self { + // CLightning's constructor stores state 1, which means the first + // active update schedules a delay immediately. + phase: 1, + last_time_ms: 0, + deadline_ms: 0, + active: false, + visual: None, + } + } + + fn clear_visual(&mut self) { + self.visual = None; + } + + fn update( + &mut self, + now_ms: u64, + weather: WeatherState, + camera: Camera, + effect: Option<&LightningEffect>, + bounds: Option, + rng: &mut NativeRng, + primitives: &mut Vec, + point_lights: &mut Vec, + sounds: &mut Vec, + ) { + if !weather.active { + // The atmosphere destroys and recreates the lightning object on a + // stop/start transition. Reproduce that lifecycle so an old + // deadline cannot fire after clear weather. + self.active = false; + self.phase = 1; + self.last_time_ms = 0; + self.deadline_ms = 0; + self.visual = None; + return; + } + if !self.active { + self.active = true; + self.phase = 1; + self.last_time_ms = 0; + self.deadline_ms = 0; + } + + self.emit_visual(now_ms, weather.color, camera, primitives, point_lights); + + // Keep the state machine's one-transition-per-call behaviour. The + // original virtual update returns after changing state 0->1 or + // state 1->2; it does not catch up multiple lightning events after a + // long frame. + match self.phase { + 0 => { + if now_ms.saturating_sub(self.last_time_ms) >= LIGHTNING_COOLDOWN_MS { + self.phase = 1; + } + } + 1 => { + if weather.intensity <= 0.0 { + return; + } + let intensity = weather.intensity.min(LIGHTNING_INTENSITY_CAP); + let random = rng.next_unit(); + let delay = ((1.0 - f64::from(intensity)) * LIGHTNING_DELAY_MS * f64::from(random)) + .round_ties_even(); + let delay = if delay.is_finite() && delay >= 0.0 { + delay.min(u64::MAX as f64) as u64 + } else { + 0 + }; + self.deadline_ms = now_ms.saturating_add(delay); + self.phase = 2; + } + 2 => { + if now_ms < self.deadline_ms { + return; + } + let position = self.random_position(camera, bounds, rng); + if let Some(effect) = effect { + // CLightning consumes one CRT random value for the + // effect seed after sampling the X and Y coordinates. + // The seed has no renderer-facing field, but it remains + // part of the shared native stream. + let effect_seed = rng.next_unit(); + self.visual = Some(LightningRuntime { + effect: effect.clone(), + position, + intensity: weather.intensity, + mirrored: effect_seed < 0.5, + started_ms: now_ms, + }); + let parameters = effect.sound_parameters(0.0).unwrap_or_default(); + sounds.push(SoundEvent { + archive: effect.sound.archive.clone(), + name: effect.sound.name.clone(), + position, + // The native opcode-2 command owns its frequency and + // range; weather intensity controls scheduling, not + // the thunder sample's source gain. + volume: 1.0, + min_distance: parameters.min_distance, + max_distance: parameters.max_distance, + frequency_ratio: parameters.frequency_ratio, + action: SoundAction::OneShot, + }); + self.emit_visual(now_ms, weather.color, camera, primitives, point_lights); + } + self.last_time_ms = now_ms; + self.phase = 0; + } + _ => self.phase = 1, + } + } + + fn emit_visual( + &mut self, + now_ms: u64, + _color: [f32; 4], + camera: Camera, + primitives: &mut Vec, + point_lights: &mut Vec, + ) { + let Some(visual) = self.visual.as_ref() else { + return; + }; + let duration_ms = seconds_to_millis(visual.effect.duration_seconds); + let elapsed_ms = now_ms.saturating_sub(visual.started_ms); + if elapsed_ms >= duration_ms.max(1) { + self.visual = None; + return; + } + // CLightning creates the Effect object 300 world units above the + // sampled ground point. Its opcode 3 type-1 quad keeps a 600-unit + // world-Z streak axis and billboards only its horizontal axis. + let origin = [ + visual.position[0], + visual.position[1], + visual.position[2] + LIGHTNING_NATIVE_ORIGIN_OFFSET, + ]; + let basis = diagonal_basis(lightning_parent_diagonal(visual.mirrored)); + let elapsed_seconds = elapsed_ms as f32 * 0.001; + if let Some(point_light) = + visual + .effect + .evaluate_point_light_with_basis(elapsed_seconds, origin, basis) + { + point_lights.push(point_light); + } + if let Some(quad) = visual.effect.evaluate_quad_with_mirror( + elapsed_seconds, + origin, + camera, + visual.mirrored, + ) { + primitives.push(EnvironmentPrimitive::Lightning(LightningBolt { + material: visual.effect.visual.clone(), + position: visual.position, + quad, + intensity: visual.intensity, + age_seconds: elapsed_seconds, + duration_seconds: visual.effect.duration_seconds, + })); + } + } + + fn random_position( + &mut self, + camera: Camera, + bounds: Option, + rng: &mut NativeRng, + ) -> [f32; 3] { + let x_random = rng.next_unit(); + let y_random = rng.next_unit(); + let bounds = bounds.unwrap_or_else(|| { + LightningBounds::new( + camera.position[0], + camera.position[0], + camera.position[1], + camera.position[1], + camera.position[2], + ) + }); + [ + bounds.x[0] + (bounds.x[1] - bounds.x[0]) * x_random, + bounds.y[0] + (bounds.y[1] - bounds.y[0]) * y_random, + bounds.z, + ] + } +} + +fn from_atmosphere_weather(sample: &crate::atmosphere::WeatherSample) -> WeatherState { + WeatherState::with_resources( + sample.active, + sample.intensity, + sample.color, + sample.resources.iter().map(|resource| resource.as_str()), + ) +} + +fn split_resource(resource: &str) -> (String, String) { + resource.split_once('/').map_or_else( + || (String::new(), resource.to_owned()), + |(archive, name)| (archive.to_owned(), name.to_owned()), + ) +} + +fn seconds_to_millis(seconds: f32) -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + let millis = f64::from(seconds) * 1_000.0; + if millis >= u64::MAX as f64 { + u64::MAX + } else { + millis.round() as u64 + } +} + +fn read_resource( + command_index: usize, + opcode: FxOpcode, + body: &[u8], + archive_offset: usize, +) -> Result { + let archive = read_name(body, command_index, opcode, archive_offset)?; + let name = read_name(body, command_index, opcode, archive_offset + 32)?; + Ok(EffectResource { archive, name }) +} + +fn validate_opcode1_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> { + let required = 132; + body.get(..required) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode: FxOpcode::Op1, + required, + actual: body.len(), + })?; + for (index, offset) in (4..required).step_by(4).enumerate() { + let value = read_f32(body, offset).expect("validated opcode 1 layout"); + if !value.is_finite() { + return Err(EnvironmentError::NonFiniteVisualValue { + command_index, + index, + value, + }); + } + } + Ok(()) +} + +fn validate_opcode3_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> { + let required = 132; + body.get(..required) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode: FxOpcode::Op3, + required, + actual: body.len(), + })?; + for (index, offset) in (4..required).step_by(4).enumerate() { + let value = read_f32(body, offset).expect("validated opcode 3 layout"); + if !value.is_finite() { + return Err(EnvironmentError::NonFiniteVisualValue { + command_index, + index, + value, + }); + } + } + Ok(()) +} + +fn validate_opcode2_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> { + let required = 144; + body.get(..required) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode: FxOpcode::Op2, + required, + actual: body.len(), + })?; + for (index, offset) in (4..80).step_by(4).enumerate() { + let value = read_f32(body, offset).expect("validated opcode 2 layout"); + if !value.is_finite() { + return Err(EnvironmentError::NonFiniteSoundValue { + command_index, + index, + value, + }); + } + } + Ok(()) +} + +fn active_interval(body: &[u8], elapsed: f32, duration: f32) -> Option<(f32, f32)> { + let start = read_f32(body, 4)?; + let end = read_f32(body, 8)?; + if !start.is_finite() + || !end.is_finite() + || !duration.is_finite() + || duration <= 0.0 + || elapsed < start + || elapsed > end + { + return None; + } + let span = end - start; + let normalized = if span.abs() > f32::EPSILON { + ((elapsed - start) / span).clamp(0.0, 1.0) + } else { + 0.0 + }; + Some((normalized, end.min(duration))) +} + +fn evaluate_position(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> { + let start = read_vec3(body, 36)?; + let delta = read_vec3(body, 48)?; + let exponent = read_vec3(body, 60)?; + Some(std::array::from_fn(|axis| { + let input = normalized_time.powf(exponent[axis]); + start[axis] + delta[axis] * input + })) +} + +fn evaluate_scale(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> { + let start = read_vec3(body, 96)?; + let end = read_vec3(body, 108)?; + let exponent = read_vec3(body, 120)?; + Some(std::array::from_fn(|axis| { + let input = normalized_time.powf(exponent[axis]); + lerp(start[axis], end[axis], input) + })) +} + +fn read_name( + body: &[u8], + command_index: usize, + opcode: FxOpcode, + offset: usize, +) -> Result { + let end = offset + .checked_add(32) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode, + required: usize::MAX, + actual: body.len(), + })?; + let bytes = body + .get(offset..end) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode, + required: end, + actual: body.len(), + })?; + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + Ok(String::from_utf8_lossy(&bytes[..end]).into_owned()) +} + +fn volume_product(extent: [f32; 3]) -> f32 { + extent[0] * extent[1] * extent[2] +} + +fn finite_unit(vector: [f32; 3]) -> Option<[f32; 3]> { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + if length.is_finite() && length > f32::EPSILON { + Some(vector.map(|component| component / length)) + } else { + None + } +} + +fn wrap_local(value: f32, extent: f32) -> f32 { + if !value.is_finite() || !extent.is_finite() || extent <= 0.0 { + return 0.0; + } + value - (value / extent).floor() * extent +} + +fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +fn scale3(value: [f32; 3], scale: f32) -> [f32; 3] { + [value[0] * scale, value[1] * scale, value[2] * scale] +} + +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])) +} + +fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1].mul_add(right[2], -(left[2] * right[1])), + left[2].mul_add(right[0], -(left[0] * right[2])), + left[0].mul_add(right[1], -(left[1] * right[0])), + ] +} + +fn lightning_parent_diagonal(mirrored: bool) -> [f32; 3] { + [ + LIGHTNING_QUAD_DIMENSIONS[0], + if mirrored { + -LIGHTNING_QUAD_DIMENSIONS[1] + } else { + LIGHTNING_QUAD_DIMENSIONS[1] + }, + LIGHTNING_QUAD_DIMENSIONS[2], + ] +} + +fn normalized_or_fallback(vector: [f32; 3], fallback: [f32; 3]) -> [f32; 3] { + finite_unit(vector).unwrap_or(fallback) +} + +fn transform_diagonal(diagonal: [f32; 3], vector: [f32; 3]) -> [f32; 3] { + [ + diagonal[0] * vector[0], + diagonal[1] * vector[1], + diagonal[2] * vector[2], + ] +} + +fn diagonal_basis(diagonal: [f32; 3]) -> [[f32; 3]; 3] { + [ + [diagonal[0], 0.0, 0.0], + [0.0, diagonal[1], 0.0], + [0.0, 0.0, diagonal[2]], + ] +} + +fn identity_basis() -> [[f32; 3]; 3] { + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] +} + +fn read_u32(body: &[u8], offset: usize) -> Option { + let bytes = body.get(offset..offset.checked_add(4)?)?; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_f32(body: &[u8], offset: usize) -> Option { + Some(f32::from_bits(read_u32(body, offset)?)) +} + +fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset.checked_add(4)?)?, + read_f32(body, offset.checked_add(8)?)?, + ]) +} + +fn read_vec4(body: &[u8], offset: usize) -> Option<[f32; 4]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset.checked_add(4)?)?, + read_f32(body, offset.checked_add(8)?)?, + read_f32(body, offset.checked_add(12)?)?, + ]) +} + +fn lerp(start: f32, end: f32, t: f32) -> f32 { + start + (end - start) * t +} + +fn lerp3(start: [f32; 3], end: [f32; 3], t: f32) -> [f32; 3] { + [ + lerp(start[0], end[0], t), + lerp(start[1], end[1], t), + lerp(start[2], end[2], t), + ] +} + +fn lerp4(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] { + [ + lerp(start[0], end[0], t), + lerp(start[1], end[1], t), + lerp(start[2], end[2], t), + lerp(start[3], end[3], t), + ] +} + +fn transform3(basis: [[f32; 3]; 3], local: [f32; 3]) -> [f32; 3] { + [ + basis[0][0].mul_add( + local[0], + basis[1][0].mul_add(local[1], basis[2][0] * local[2]), + ), + basis[0][1].mul_add( + local[0], + basis[1][1].mul_add(local[1], basis[2][1] * local[2]), + ), + basis[0][2].mul_add( + local[0], + basis[1][2].mul_add(local[1], basis[2][2] * local[2]), + ), + ] +} + +fn sanitize_float(value: f32) -> f32 { + if value.is_finite() { + value + } else { + 0.0 + } +} + +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn screen_point(camera: Camera, point: [f32; 3]) -> ([f32; 2], f32) { + let delta = sub3(point, camera.position); + let depth = dot3(camera.forward, delta); + let depth_for_projection = depth.max(f32::EPSILON); + let half_vertical = (camera.vertical_fov * 0.5).tan(); + let half_horizontal = half_vertical * camera.aspect_ratio; + let ndc_x = dot3(camera.right, delta) / (depth_for_projection * half_horizontal); + let ndc_y = dot3(camera.up, delta) / (depth_for_projection * half_vertical); + let x = (ndc_x + 1.0) * 0.5 * camera.viewport[0]; + let y = (1.0 - ndc_y) * 0.5 * camera.viewport[1]; + ([x, y], depth) +} + +fn pixel_to_ndc(camera: Camera, point: [f32; 2]) -> [f32; 2] { + [ + point[0] * 2.0 / camera.viewport[0] - 1.0, + 1.0 - point[1] * 2.0 / camera.viewport[1], + ] +} + +fn screen_billboard( + kind: PrecipitationKind, + camera: Camera, + head_world: [f32; 3], + tail_world: [f32; 3], + size: f32, + index: usize, +) -> ScreenBillboard { + let (head_pixels, depth) = screen_point(camera, head_world); + let (tail_pixels, tail_depth) = screen_point(camera, tail_world); + let near = PRECIPITATION_NEAR; + let extent_x = PRECIPITATION_FAR - PRECIPITATION_NEAR; + let depth_fade = (1.0 - (depth - near) / extent_x).max(0.0); + let half_size = match kind { + PrecipitationKind::Rain => size * 0.5 * depth_fade, + PrecipitationKind::Snow => size * 0.5 * depth_fade.max(SNOW_MIN_DEPTH_FADE), + }; + let (corners_pixels, uv) = match kind { + PrecipitationKind::Rain => { + let direction = [ + head_pixels[0] - tail_pixels[0], + head_pixels[1] - tail_pixels[1], + ]; + let direction_length = + (direction[0] * direction[0] + direction[1] * direction[1]).sqrt(); + let perpendicular = if direction_length.is_finite() && direction_length > f32::EPSILON { + [ + -direction[1] / direction_length, + direction[0] / direction_length, + ] + } else { + [1.0, 0.0] + }; + let offset = [perpendicular[0] * half_size, perpendicular[1] * half_size]; + ( + [ + [tail_pixels[0] - offset[0], tail_pixels[1] - offset[1]], + [tail_pixels[0] + offset[0], tail_pixels[1] + offset[1]], + [head_pixels[0] - offset[0], head_pixels[1] - offset[1]], + [head_pixels[0] + offset[0], head_pixels[1] + offset[1]], + ], + [[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]], + ) + } + PrecipitationKind::Snow => { + let signs = [[1.0, -1.0], [1.0, 1.0], [-1.0, -1.0], [-1.0, 1.0]]; + let mirror = index & 0x10 != 0; + let corners = std::array::from_fn(|corner| { + let [mut x, mut y] = signs[corner]; + if mirror { + std::mem::swap(&mut x, &mut y); + } + [ + head_pixels[0] + x * half_size, + head_pixels[1] + y * half_size, + ] + }); + let uv = if mirror { + [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]] + } else { + [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]] + }; + (corners, uv) + } + }; + let head = pixel_to_ndc(camera, head_pixels); + let tail = pixel_to_ndc(camera, tail_pixels); + let corners = corners_pixels.map(|corner| pixel_to_ndc(camera, corner)); + ScreenBillboard { + world_head: head_world, + world_tail: tail_world, + head, + tail, + corners, + uv, + half_size, + head_depth: depth, + tail_depth, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_rain_is_camera_local_and_uses_sky_material() { + let mut system = EnvironmentSystem::new(7); + let weather = WeatherInput { + rain: WeatherState::with_resources(true, 0.5, [0.7, 0.8, 1.0, 0.6], ["atm_rain1.wav"]), + ..WeatherInput::clear() + }; + let frame = system.update(1.0, weather.clone(), Camera::new([10.0, -2.0, 4.0])); + let particles = frame + .primitives + .iter() + .filter_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { + kind, + position, + velocity, + material, + .. + } => Some((*kind, *position, *velocity, material.as_str())), + _ => None, + }) + .collect::>(); + + assert_eq!( + particles.len(), + system.precipitation_volume().particle_count(0.5) + ); + assert!(particles + .iter() + .all(|(kind, position, velocity, material)| { + *kind == PrecipitationKind::Rain + && *material == system.materials.material_name(8).unwrap() + && *velocity == RAIN_VELOCITY + && system + .precipitation_volume() + .bounds(Camera::new([10.0, -2.0, 4.0])) + .contains(*position) + })); + assert_eq!(frame.sounds.len(), 1); + assert_eq!(frame.sounds[0].name, "atm_rain1.wav"); + assert_eq!(frame.sounds[0].action, SoundAction::StartLoop); + + let update = system.update(0.1, weather, Camera::new([10.0, -2.0, 4.0])); + assert_eq!(update.sounds.len(), 1); + assert_eq!(update.sounds[0].action, SoundAction::SetLoopVolume); + assert_eq!(update.sounds[0].volume, 0.5); + } + + #[test] + fn precipitation_uses_native_volume_ratio_and_nearest_even_count() { + let reference = PrecipitationVolume::native(); + assert_eq!(reference.particle_count_for(reference, 1.0, 0.5004), 500); + assert_eq!(reference.particle_count_for(reference, 1.0, 0.5016), 502); + + let narrow_camera = Camera::with_projection( + [0.0; 3], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + 0.4875, + 1.0, + ); + let current = reference.for_camera(narrow_camera); + assert!(reference.particle_count_for(current, 1.0, 1.0) < reference.particle_count(1.0)); + } + + #[test] + fn precipitation_keeps_world_velocity_when_camera_turns_and_wraps() { + let mut system = EnvironmentSystem::new(3); + let camera = Camera::new([10.0, 20.0, 30.0]); + let weather = WeatherInput { + rain: WeatherState::new(true, 0.01, [1.0; 4]), + ..WeatherInput::clear() + }; + let initial = system.update(0.0, weather.clone(), camera); + let initial_position = initial + .primitives + .iter() + .find_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { position, .. } => Some(*position), + _ => None, + }) + .expect("initial rain particle"); + let turned_camera = Camera::with_basis( + [10.0, 20.0, 30.0], + [0.0, 1.0, 0.0], + [-1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ); + let turned = system.update(0.0, weather.clone(), turned_camera); + let turned_position = turned + .primitives + .iter() + .find_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { position, .. } => Some(*position), + _ => None, + }) + .expect("turned rain particle"); + assert!(sub3(initial_position, turned_position) + .into_iter() + .all(|component| component.abs() < 1.0e-5)); + + let frame = system.update(10.0, weather, camera); + let particle = frame + .primitives + .iter() + .find_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { + kind, + position, + velocity, + size, + screen, + .. + } => Some((*kind, *position, *velocity, *size, *screen)), + _ => None, + }) + .expect("rain particle"); + assert_eq!(particle.0, PrecipitationKind::Rain); + assert_eq!(particle.2, RAIN_VELOCITY); + assert_eq!( + particle.3, + camera.precipitation_size_scalar() * RAIN_SIZE_FACTOR + ); + assert!(system + .precipitation_volume() + .bounds(camera) + .contains(particle.1)); + assert!(particle + .4 + .corners + .iter() + .flatten() + .all(|value| value.is_finite())); + } + + #[test] + fn precipitation_size_uses_viewport_width_and_horizontal_fov() { + let camera = Camera::with_projection( + [0.0; 3], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + 0.975, + 16.0 / 9.0, + ) + .with_viewport([1920.0, 1080.0]); + let expected_fov = 2.0 * ((0.975_f32 * 0.5).tan() * (16.0_f32 / 9.0)).atan(); + assert!((camera.horizontal_fov() - expected_fov).abs() < 1.0e-6); + assert!((camera.precipitation_size_scalar() - 1920.0 / expected_fov).abs() < 1.0e-4); + } + + #[test] + fn lightning_fixture_extracts_real_material_and_sound_offsets() { + let mut bytes = header(3, 0.75); + // The shipped env_lightning FXID uses opcode words 3, 1, and 2 with + // bit 8 clear. Bit 8 is a native mode flag, not command enablement. + let mut visual = command(0x0003, 200); + visual[4..8].copy_from_slice(&1_u32.to_le_bytes()); + copy_name(&mut visual[4 + 132..4 + 164], b"material.lib"); + copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning"); + bytes.extend_from_slice(&visual); + let mut opcode1 = command(0x0001, 224); + opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes()); + opcode1[12..16].copy_from_slice(&0.99_f32.to_le_bytes()); + bytes.extend_from_slice(&opcode1); + let mut sound = command(0x0002, 148); + copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib"); + copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav"); + bytes.extend_from_slice(&sound); + + let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice())) + .expect("env_lightning fixture"); + assert_eq!(effect.duration_seconds, 0.75); + assert_eq!( + effect.visual, + EffectResource { + archive: "material.lib".to_owned(), + name: "env_lightning".to_owned() + } + ); + assert_eq!( + effect.sound, + EffectResource { + archive: "sounds.lib".to_owned(), + name: "atm_light1.wav".to_owned() + } + ); + } + + #[test] + fn opcode2_sound_parameters_use_native_distance_and_frequency_offsets() { + let mut bytes = header(3, 1.0); + let mut visual = command(0x0003, 200); + visual[4..8].copy_from_slice(&1_u32.to_le_bytes()); + copy_name(&mut visual[4 + 132..4 + 164], b"material.lib"); + copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning"); + bytes.extend_from_slice(&visual); + let mut opcode1 = command(0x0001, 224); + opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes()); + bytes.extend_from_slice(&opcode1); + let mut sound = command(0x0002, 148); + let body = &mut sound[4..]; + body[4..8].copy_from_slice(&0.0_f32.to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + body[60..64].copy_from_slice(&100.0_f32.to_le_bytes()); + body[64..68].copy_from_slice(&1_500.0_f32.to_le_bytes()); + body[68..72].copy_from_slice(&0.75_f32.to_le_bytes()); + body[72..76].copy_from_slice(&1.25_f32.to_le_bytes()); + body[76..80].copy_from_slice(&0_u32.to_le_bytes()); + copy_name(&mut body[80..112], b"sounds.lib"); + copy_name(&mut body[112..144], b"atm_light1.wav"); + bytes.extend_from_slice(&sound); + + let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice())) + .expect("env_lightning sound fixture"); + assert_eq!( + effect.sound_parameters(0.5), + Some(SoundParameters { + min_distance: 100.0, + max_distance: 1_500.0, + frequency_ratio: 1.0, + }) + ); + } + + #[test] + fn opcode1_evaluator_emits_native_point_light_groups_and_maps_parent_axis() { + let mut bytes = header(3, 0.75); + let mut visual = command(0x0003, 200); + visual[4..8].copy_from_slice(&1_u32.to_le_bytes()); + copy_name(&mut visual[4 + 132..4 + 164], b"material.lib"); + copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning"); + visual[4 + 4..4 + 8].copy_from_slice(&(-1.0_f32).to_le_bytes()); + visual[4 + 8..4 + 12].copy_from_slice(&1.0_f32.to_le_bytes()); + for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] { + visual[4 + offset..4 + offset + 4].copy_from_slice(&1.0_f32.to_le_bytes()); + } + bytes.extend_from_slice(&visual); + let mut opcode1 = command(0x0001, 224); + let body = &mut opcode1[4..]; + body[0..4].copy_from_slice(&7_u32.to_le_bytes()); + body[4..8].copy_from_slice(&0.0_f32.to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + body[12..24].copy_from_slice( + &[0.0_f32; 3] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[24..36].copy_from_slice( + &[2.0_f32, 4.0, 6.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[36..48].copy_from_slice( + &[1.0_f32, 0.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[48..60].copy_from_slice( + &[0.0_f32, 1.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[60..76].copy_from_slice( + &[7.0_f32, 7.0, 10.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[76..92].copy_from_slice( + &[0.0_f32, 0.0, 0.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[108..112].copy_from_slice(&100.0_f32.to_le_bytes()); + body[112..116].copy_from_slice(&0.0_f32.to_le_bytes()); + body[116..120].copy_from_slice(&0.25_f32.to_le_bytes()); + body[120..124].copy_from_slice(&0.25_f32.to_le_bytes()); + body[124..128].copy_from_slice(&0.75_f32.to_le_bytes()); + body[128..132].copy_from_slice(&0.75_f32.to_le_bytes()); + bytes.extend_from_slice(&opcode1); + let mut sound = command(0x0002, 148); + copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib"); + copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav"); + bytes.extend_from_slice(&sound); + + let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice())) + .expect("env_lightning evaluator fixture"); + let basis = [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + let frame = effect + .evaluate_point_light_with_basis(0.5, [10.0, 20.0, 30.0], basis) + .expect("active opcode 1 sample"); + assert_eq!(frame.effect_type, 7); + assert_eq!(frame.position, [12.0, 23.0, 31.0]); + let diagonal = 2.0_f32.sqrt().recip(); + assert!((frame.direction[0] - diagonal).abs() < 1.0e-6); + assert_eq!(frame.direction[1], 0.0); + assert!((frame.direction[2] - diagonal).abs() < 1.0e-6); + assert_eq!(frame.color, [3.5, 3.5, 5.0, 0.0]); + assert_eq!(frame.range, 50.0); + assert_eq!(frame.attenuation, [0.25, 0.75, 0.75]); + assert_eq!(frame.effect_factor, 0.25); + assert!(frame.active); + let quad = effect + .evaluate_quad(0.5, [10.0, 20.0, 330.0], Camera::new([0.0, 0.0, 0.0])) + .expect("active opcode 3 sample"); + assert_eq!(quad.dimensions, LIGHTNING_QUAD_DIMENSIONS); + assert_eq!( + quad.uv, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]] + ); + assert_eq!(quad.opacity, 0.25); + assert!(quad + .vertices + .iter() + .flatten() + .all(|value| value.is_finite())); + assert!(effect.evaluate_point_light(1.01, [0.0; 3]).is_none()); + } + + #[test] + fn opcode3_quad_applies_parent_diagonal_before_offset_and_scale() { + let mut body = vec![0; 196]; + body[0..4].copy_from_slice(&1_u32.to_le_bytes()); + body[4..8].copy_from_slice(&0.0_f32.to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + body[36..48].copy_from_slice( + &[1.0_f32, 2.0, 0.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[48..60].copy_from_slice( + &[1.0_f32, 0.0, 0.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[60..72].copy_from_slice( + &[1.0_f32; 3] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[96..108].copy_from_slice( + &[2.0_f32, 3.0, 1.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[108..120].copy_from_slice( + &[4.0_f32, 5.0, 2.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[120..132].copy_from_slice( + &[1.0_f32; 3] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + let effect = LightningEffect { + duration_seconds: 1.0, + visual: EffectResource { + archive: "material.lib".to_owned(), + name: "env_lightning".to_owned(), + }, + visual_body: Arc::from(body.into_boxed_slice()), + opcode1_body: Arc::from(vec![0; 132].into_boxed_slice()), + opcode1_command_index: 1, + sound: EffectResource { + archive: "sounds.lib".to_owned(), + name: "atm_light1.wav".to_owned(), + }, + sound_body: Arc::from(vec![0; 144].into_boxed_slice()), + visual_command_index: 0, + sound_command_index: 2, + }; + let quad = effect + .evaluate_quad(0.5, [10.0, 20.0, 30.0], Camera::new([130.0, 80.0, 100.0])) + .expect("synthetic opcode 3 sample"); + assert_eq!(quad.local_offset, [1.5, 2.0, 0.0]); + assert_eq!(quad.local_scale, [3.0, 4.0, 1.5]); + assert_eq!(quad.dimensions, [120.0, 160.0, 900.0]); + let center = quad + .vertices + .iter() + .fold([0.0; 3], |sum, vertex| add3(sum, *vertex)) + .map(|component| component * 0.25); + assert_eq!(center, [70.0, 100.0, 30.0]); + assert!((quad.vertices[2][2] - quad.vertices[0][2] - 900.0).abs() < 1.0e-5); + + let parallel_camera = Camera::new([70.0, 100.0, 630.0]); + assert!(effect + .evaluate_quad(0.5, [10.0, 20.0, 30.0], parallel_camera) + .is_none()); + let fallback_camera = Camera::new([70.0, 100.0, 1_230.0]); + let fallback = effect + .evaluate_quad(0.5, [10.0, 20.0, 30.0], fallback_camera) + .expect("non-unit parallel view uses native fallback axis"); + assert_eq!(fallback.vertices[0][1], fallback.vertices[1][1]); + assert!((fallback.vertices[0][0] - fallback.vertices[1][0] - 120.0).abs() < 1.0e-5); + } + + #[test] + fn lightning_uses_native_delay_cooldown_and_fx_lifetime() { + let effect = LightningEffect { + duration_seconds: 0.75, + visual: EffectResource { + archive: "material.lib".to_owned(), + name: "env_lightning".to_owned(), + }, + visual_body: { + let mut body = vec![0; 196]; + body[0..4].copy_from_slice(&1_u32.to_le_bytes()); + body[4..8].copy_from_slice(&(-1.0_f32).to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] { + body[offset..offset + 4].copy_from_slice(&1.0_f32.to_le_bytes()); + } + Arc::from(body.into_boxed_slice()) + }, + opcode1_body: { + let mut body = vec![0; 220]; + body[8..12].copy_from_slice(&0.75_f32.to_le_bytes()); + body[36..40].copy_from_slice(&1.0_f32.to_le_bytes()); + body[128..132].copy_from_slice(&1.0_f32.to_le_bytes()); + Arc::from(body.into_boxed_slice()) + }, + opcode1_command_index: 1, + sound: EffectResource { + archive: "sounds.lib".to_owned(), + name: "atm_light1.wav".to_owned(), + }, + sound_body: Arc::from(vec![0; 144].into_boxed_slice()), + visual_command_index: 0, + sound_command_index: 2, + }; + let mut system = EnvironmentSystem::new(11); + system.set_lightning_effect(effect); + let weather = WeatherInput { + lightning: WeatherState::new(true, 1.0, [1.0; 4]), + ..WeatherInput::clear() + }; + let camera = Camera::new([50.0, 0.0, 0.0]); + system.set_lightning_bounds(LightningBounds::new(0.0, 0.0, 0.0, 0.0, 0.0)); + // The constructor starts in native state 1, which schedules a random + // delay. No external trigger sequence is needed. + let first = system.update(0.1, weather.clone(), camera); + assert!(first.sounds.is_empty()); + assert_eq!(system.lightning.phase, 2); + + let mut one_shots = 0; + let mut effect_frames = 0; + let mut point_light_frames = 0; + let mut inspected_shape = false; + for _ in 0..200 { + let frame = system.update(0.1, weather.clone(), camera); + point_light_frames += frame.point_lights.len(); + one_shots += frame + .sounds + .iter() + .filter(|sound| sound.action == SoundAction::OneShot) + .count(); + for primitive in &frame.primitives { + if let EnvironmentPrimitive::Lightning(bolt) = primitive { + inspected_shape = true; + assert_eq!(bolt.quad.dimensions, LIGHTNING_QUAD_DIMENSIONS); + assert!(bolt + .quad + .vertices + .iter() + .flatten() + .all(|value| value.is_finite())); + assert_eq!( + bolt.quad.uv, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]] + ); + assert!(bolt.age_seconds >= 0.0 && bolt.age_seconds < bolt.duration_seconds); + assert_eq!(bolt.duration_seconds, 0.75); + effect_frames += 1; + } + } + } + assert!(one_shots >= 2, "native timer must repeat lightning"); + assert!(effect_frames > 0); + assert!(inspected_shape); + assert!(point_light_frames > 0); + + let clear = system.update(0.1, WeatherInput::clear(), camera); + assert!(clear.primitives.is_empty()); + assert!(clear.point_lights.is_empty()); + assert!(clear.sounds.is_empty()); + } + + #[test] + fn fractional_milliseconds_do_not_drift_at_120_fps() { + let mut system = EnvironmentSystem::new(1); + for _ in 0..120 { + let _ = system.update(1.0 / 120.0, WeatherInput::clear(), Camera::default()); + } + assert_eq!(system.time_millis(), 1_000); + } + + fn header(command_count: u32, duration: f32) -> Vec { + let mut out = Vec::with_capacity(60); + out.extend_from_slice(&command_count.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&duration.to_bits().to_le_bytes()); + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + for _ in 0..9 { + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + } + out + } + + fn command(word: u32, size: usize) -> Vec { + let mut out = vec![0; size]; + out[..4].copy_from_slice(&word.to_le_bytes()); + out + } + + fn copy_name(dst: &mut [u8], name: &[u8]) { + dst[..name.len()].copy_from_slice(name); + } +} diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 3fa4aae..437ae5c 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -6,6 +6,11 @@ //! reference stub until opcode timing, gates, RNG, and command-body semantics //! are backed by runtime-captured evidence. +pub mod atmosphere; +pub mod environment; +pub mod shadow; +pub mod sky; + use fparkan_binary::{Cursor, DecodeError}; use std::sync::Arc; @@ -85,8 +90,13 @@ pub struct FxCommand { pub word: u32, /// Decoded opcode. pub opcode: FxOpcode, - /// Enabled bit. - pub enabled: bool, + /// Native command flag stored in bit 8 of the command word. + /// + /// This flag is passed to the native command object as a mode/feature + /// flag. It does not disable the command: real effects such as + /// `env_lightning` use zero for all three command flags and still execute + /// opcode 3, opcode 1, and opcode 2. + pub native_flag: bool, /// Command body after the word. pub raw_body: Arc<[u8]>, /// Resource references discovered in known command layouts. @@ -314,7 +324,7 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result { commands.push(FxCommand { word, opcode, - enabled: ((word >> 8) & 1) != 0, + native_flag: ((word >> 8) & 1) != 0, raw_body, resource_refs, }); @@ -369,9 +379,6 @@ pub fn emit(state: &FxState, out: &mut Vec) -> Result<(), FxError> { return Ok(()); } for (index, command) in state.document.commands.iter().enumerate() { - if !command.enabled { - continue; - } let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?; if command.opcode == FxOpcode::Op2 { out.push(FxEmission::Sound(FxSoundEvent { command_index })); @@ -605,7 +612,7 @@ mod tests { assert_eq!(document.header().command_count, 2); assert_eq!(document.commands()[0].opcode, FxOpcode::Op2); - assert!(document.commands()[0].enabled); + assert!(document.commands()[0].native_flag); assert_eq!( document.commands()[0].resource_refs[0].archive_name(), b"sounds.lib" @@ -640,6 +647,25 @@ mod tests { assert!(document.commands()[0].raw_body.is_empty()); } + #[test] + fn zero_native_flags_still_emit_every_command() { + let mut bytes = header(3); + bytes.extend_from_slice(&command(0x0003, 200)); + bytes.extend_from_slice(&command(0x0001, 224)); + bytes.extend_from_slice(&command(0x0002, 148)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + + assert!(document + .commands() + .iter() + .all(|command| !command.native_flag)); + let state = create_instance(document, FxSeed(9), Transform::default()).expect("state"); + assert_eq!( + canonical_emission_capture(&state).expect("capture"), + b"P,0, Op3\nP,1, Op1\nS,2\n" + ); + } + #[test] fn rejects_unknown_opcode_at_command_index() { let mut bytes = header(1); diff --git a/crates/fparkan-fx/src/shadow.rs b/crates/fparkan-fx/src/shadow.rs new file mode 100644 index 0000000..64e2b8e --- /dev/null +++ b/crates/fparkan-fx/src/shadow.rs @@ -0,0 +1,1785 @@ +//! CPU reference implementation of the native projected shadow path. +//! +//! The original renderer keeps shadow pages in one 256 by 256 ARGB4444 +//! texture. This module deliberately keeps that work independent from a +//! graphics backend: callers provide world-space caster and receiver +//! triangles, and receive an RGBA8 atlas plus ordinary projected geometry. + +use std::error::Error; +use std::fmt; + +/// Native shadow atlas width in texels. +pub const SHADOW_ATLAS_WIDTH: usize = 256; +/// Native shadow atlas height in texels. +pub const SHADOW_ATLAS_HEIGHT: usize = 256; +/// Number of native page slots. +pub const SHADOW_PAGE_COUNT: usize = 28; +/// Maximum number of shadow casters considered by the native frame builder. +/// +/// The atlas has 28 physical slots, but the native `CShade` object allocates +/// only 20 live shadow records per frame. +pub const SHADOW_MAX_ACTIVE_CASTERS: usize = 20; +/// Native primary directional-light flag. +pub const SHADOW_PRIMARY_DIRECTIONAL_FLAG: u32 = 0x0800_0000; +/// Full receiver flag used by the ordinary shadow query. +/// +/// `ShadowTriangle::mask` contains the full decoded face flags. The native +/// general query excludes a triangle when this bit is present. +pub const SHADOW_RECEIVER_MASK: u32 = 0x2000; +/// Compact query mask passed to the native four-ray world call. +pub const SHADOW_FOUR_RAY_QUERY_MASK_COMPACT: u32 = 0x8008; +/// Full receiver flags produced by expanding [`SHADOW_FOUR_RAY_QUERY_MASK_COMPACT`]. +/// +/// Terrain expands the compact descriptor before testing a receiver triangle, +/// so this is the mask that belongs in `ShadowTriangle::mask` comparisons. +pub const SHADOW_FOUR_RAY_MASK: u32 = 0x0020_0020; +const SHADOW_PROJECTION_EPSILON: f32 = -1.175_494_4e-37; +const SHADOW_CLIP_EPSILON: f32 = f32::MIN_POSITIVE; +const SHADOW_QUAD_UV_MAX: f32 = 0.99; + +/// Bounding sphere used by native light selection and page projection. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadowSphere { + /// World-space center. + pub center: [f32; 3], + /// Radius in world units. + pub radius: f32, +} + +/// World-space triangle used by a caster or receiver. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadowTriangle { + /// Triangle positions in world space. + pub positions: [[f32; 3]; 3], + /// World-space normal. `new` derives this value when the caller does not + /// already have the native normal. + pub normal: [f32; 3], + /// Full decoded native face flags used as receiver exclusion bits. + pub mask: u32, +} + +/// A world-space shadow caster. +#[derive(Clone, Debug, PartialEq)] +pub struct ShadowCaster { + /// Stable caster id from the caller's object table. + pub id: u32, + /// Native object axes. Each entry is one world-space axis. + pub world_axes: [[f32; 3]; 3], + /// Native bounding sphere. + pub sphere: ShadowSphere, + /// Native object kind (`3` is the building path and `4` is the four-ray + /// actor path). + pub native_kind: u32, + /// Entire visible UNIT triangle set in world space. + pub triangles: Vec, +} + +/// Camera values consumed by the native projected-size shadow LOD test. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadowCamera { + /// World-space camera position. + pub position: [f32; 3], + /// Viewport width in pixels. + pub viewport_width: f32, + /// Horizontal field of view in radians. + pub horizontal_fov: f32, +} + +/// Native shadow quality controls used by the page allocator and LOD test. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadowLodSettings { + /// Minimum building LOD (`kind == 3`). + pub building_min_lod: u8, + /// Building screen-size multiplier. + pub building_detail: f32, + /// Minimum BTLU/robot LOD (`kind == 4`). + pub robot_min_lod: u8, + /// Robot screen-size multiplier. + pub robot_detail: f32, +} + +impl Default for ShadowLodSettings { + fn default() -> Self { + Self { + building_min_lod: 1, + building_detail: 1.0, + robot_min_lod: 1, + robot_detail: 1.0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct ShadowCacheSlot { + caster: u32, + age: u32, +} + +/// Reusable native page ownership state. +/// +/// The original allocator keeps page ownership and an age counter between +/// frames. Keeping that small piece of state separate from the CPU raster +/// output lets callers rebuild the atlas without changing a caster's UV page +/// whenever the camera moves. +#[derive(Clone, Debug)] +pub struct ShadowPageCache { + slots: [Option; SHADOW_PAGE_COUNT], + /// Slots claimed while building the current frame. A slot already used + /// by an earlier candidate must not be evicted again by a later one. + frame_used: [bool; SHADOW_PAGE_COUNT], +} + +impl Default for ShadowPageCache { + fn default() -> Self { + Self { + slots: [None; SHADOW_PAGE_COUNT], + frame_used: [false; SHADOW_PAGE_COUNT], + } + } +} + +impl ShadowPageCache { + /// Builds a frame while preserving page ownership across calls. + pub fn build(&mut self, scene: ShadowScene<'_>) -> Result { + self.begin_frame(); + build_shadow_frame_inner(scene, Some(self)) + } + + /// Clears all page ownership. The next build will allocate from the + /// native preferred page groups again. + pub fn clear(&mut self) { + self.slots = [None; SHADOW_PAGE_COUNT]; + self.frame_used = [false; SHADOW_PAGE_COUNT]; + } + + /// Returns the cached page slot for a caster, if it currently owns one. + #[must_use] + pub fn page_slot(&self, caster: u32) -> Option { + self.slots + .iter() + .enumerate() + .filter_map(|(index, slot)| { + slot.filter(|slot| slot.caster == caster) + .map(|slot| (index, slot.age)) + }) + .min_by_key(|&(_, age)| age) + .map(|(index, _)| index) + } + + fn begin_frame(&mut self) { + self.frame_used = [false; SHADOW_PAGE_COUNT]; + for slot in &mut self.slots { + if let Some(slot) = slot { + slot.age = slot.age.saturating_add(1); + } + } + } + + fn assign(&mut self, caster: u32, lod: u8) -> Option { + // A native candidate is submitted at most once per frame. Preserve + // that invariant even when a caller repeats an id: falling through + // from the preferred group to the fallback group must not allocate a + // second page for the same caster. + if self.slots.iter().enumerate().any(|(index, slot)| { + self.frame_used[index] && slot.is_some_and(|slot| slot.caster == caster) + }) { + return None; + } + let large = lod <= 1; + let preferred = if large { 0..12 } else { 12..28 }; + let fallback = if large { 12..28 } else { 0..28 }; + let threshold = if large { 1 } else { 6 }; + self.assign_range(caster, preferred, 6) + .or_else(|| self.assign_range(caster, fallback, threshold)) + } + + /// Applies the native allocator's inclusive-range search: + /// existing owner, first free slot, then the oldest eligible slot. A + /// current-frame slot is never eligible for reuse. + fn assign_range( + &mut self, + caster: u32, + range: std::ops::Range, + threshold: u32, + ) -> Option { + let mut oldest = None; + // 30BE0 scans the inclusive page range once. An existing owner or + // the first free page wins immediately; only an occupied page past + // the age threshold is deferred until the complete scan. + for index in range { + if self.frame_used[index] { + continue; + } + match self.slots[index] { + Some(slot) if slot.caster == caster => return self.claim(index, caster), + None => return self.claim(index, caster), + Some(slot) if slot.age >= threshold => { + // Native max-age selection keeps the later physical page + // on ties. + if oldest.is_none_or(|(age, old_index)| { + slot.age > age || (slot.age == age && index > old_index) + }) { + oldest = Some((slot.age, index)); + } + } + Some(_) => {} + } + } + oldest.and_then(|(_, index)| self.claim(index, caster)) + } + + fn claim(&mut self, index: usize, caster: u32) -> Option { + if self.frame_used.get(index).copied().unwrap_or(true) { + return None; + } + self.frame_used[index] = true; + self.slots[index] = Some(ShadowCacheSlot { caster, age: 0 }); + Some(index) + } +} + +/// Native light records that can contribute to projected shadows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ShadowLight { + /// A directional sun or moon light. + Directional { + /// Native world-space direction. + direction: [f32; 3], + /// RGB intensity. + rgb: [f32; 3], + /// Whether the native slot is live. + active: bool, + /// Native light flags. + flags: u32, + }, + /// A native point light. + Point { + /// World-space point-light position. + position: [f32; 3], + /// RGB intensity. + rgb: [f32; 3], + /// Light range. + range: f32, + /// Native attenuation coefficients. + coefficients: [f32; 3], + /// Whether the native slot is live. + active: bool, + /// Native light flags. + flags: u32, + }, +} + +/// The projection transform and opacity used by one atlas page. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ShadowProjection { + /// Row-major matrix from shadow-local coordinates to world space. + pub world_from_shadow: [f32; 16], + /// Row-major matrix from world space to shadow-local coordinates. + pub shadow_from_world: [f32; 16], + /// Shadow axis (`M.Z`) used by the native receiver test. + pub shadow_axis: [f32; 3], + /// Shadow opacity before the receiver normal term. + pub opacity: f32, + /// Upper and lower native extrusion distances along `shadow_axis`. + pub upper_distance: f32, + pub lower_distance: f32, + /// Maximum light strength used by receiver opacity. + light_strength: f32, +} + +/// One occupied native atlas page. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ShadowPage { + /// Top-left atlas origin. + pub origin: [u32; 2], + /// Page side in texels, either 64 or 32. + pub size: u32, + /// Caster assigned to this page. + caster: u32, + /// Native projection transform for this page. + pub projection: ShadowProjection, +} + +/// One vertex of a projected receiver shadow. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadowVertex { + /// World-space receiver position. + pub position: [f32; 3], + /// Normalized UV into the 256 by 256 atlas. + pub uv: [f32; 2], + /// Receiver alpha after native light and normal terms. + pub alpha: f32, + /// Receiver normal passed to the backend. + pub normal: [f32; 3], +} + +/// CPU-produced shadow frame. +#[derive(Clone, Debug, PartialEq)] +pub struct ShadowFrame { + /// RGBA8 atlas. Native `F000` texels become `[0, 0, 0, 255]`. + pub atlas_rgba8: Vec, + /// Projected receiver vertices. + pub vertices: Vec, + /// Triangle-list indices into [`ShadowFrame::vertices`]. + pub indices: Vec, +} + +/// Inputs for one shadow build. +pub struct ShadowScene<'a> { + /// Active light records. + pub lights: &'a [ShadowLight], + /// Visible world-space casters. + pub casters: &'a [ShadowCaster], + /// World-space receiver triangles. + pub receivers: &'a [ShadowTriangle], + /// Native fade multiplier before the `0.2` opacity scale. + pub fade: f32, + /// Native blur passes: zero, forward once, or forward then reverse. + pub smooth_passes: u32, + /// Camera used for native projected-size LOD selection. `None` keeps the + /// CPU reference useful for callers that already performed visibility and + /// LOD selection upstream. + pub camera: Option, + /// Native shadow quality controls. + pub lod: ShadowLodSettings, +} + +/// Shadow build failures at the renderer trust boundary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ShadowError { + /// A caller supplied a non-finite or otherwise unusable value. + InvalidInput(&'static str), +} + +impl fmt::Display for ShadowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(field) => write!(f, "invalid shadow input: {field}"), + } + } +} + +impl Error for ShadowError {} + +/// Builds one native-style projected shadow frame. +pub fn build_shadow_frame(scene: ShadowScene<'_>) -> Result { + build_shadow_frame_inner(scene, None) +} + +fn build_shadow_frame_inner( + scene: ShadowScene<'_>, + mut cache: Option<&mut ShadowPageCache>, +) -> Result { + if !scene.fade.is_finite() || scene.fade < 0.0 { + return Err(ShadowError::InvalidInput("fade")); + } + if let Some(camera) = scene.camera { + if !camera.position.iter().all(|value| value.is_finite()) + || !camera.viewport_width.is_finite() + || camera.viewport_width <= 0.0 + || !camera.horizontal_fov.is_finite() + || camera.horizontal_fov <= 0.0 + { + return Err(ShadowError::InvalidInput("shadow camera")); + } + } + if !scene.lod.building_detail.is_finite() + || scene.lod.building_detail <= 0.0 + || !scene.lod.robot_detail.is_finite() + || scene.lod.robot_detail <= 0.0 + { + return Err(ShadowError::InvalidInput("shadow LOD settings")); + } + for light in scene.lights { + validate_light(light)?; + } + for receiver in scene.receivers { + validate_triangle(receiver, "receiver")?; + } + + let mut frame = ShadowFrame { + atlas_rgba8: vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4], + vertices: Vec::new(), + indices: Vec::new(), + }; + let mut pages = Vec::new(); + + let mut candidates = Vec::with_capacity(scene.casters.len().min(SHADOW_MAX_ACTIVE_CASTERS)); + for caster in scene.casters { + validate_caster(caster)?; + let lod = scene + .camera + .map(|camera| projected_shadow_lod(caster, camera, scene.lod)) + .unwrap_or(0); + if lod >= 8 { + continue; + } + candidates.push((caster, lod)); + if candidates.len() == SHADOW_MAX_ACTIVE_CASTERS { + break; + } + } + + let mut assignments = Vec::with_capacity(candidates.len()); + for (caster, lod) in candidates { + let slot = match cache.as_deref_mut() { + Some(cache) => { + let Some(slot) = cache.assign(caster.id, lod) else { + // A page that is still too young, or a duplicate caster + // in this frame, is omitted by the native allocator. Do + // not invent a stateless slot: it could overwrite a page + // already assigned to an earlier candidate. + continue; + }; + slot + } + None => assignments.len(), + }; + assignments.push((slot, caster, lod)); + } + + // Terrain owns the candidate list and performs native radius/distance + // selection. The CPU contract therefore consumes the visible list in + // caller order while preserving the native 20-record frame budget. A + // cache supplies stable physical page slots; the stateless wrapper above + // retains the original deterministic slot order for simple callers. + for (slot, caster, _lod) in assignments { + let Some(contribution) = select_light(caster, scene.lights) else { + continue; + }; + let (origin, size) = native_page_layout(slot); + let projection = make_projection(caster, contribution, scene.fade)?; + rasterize_page( + &mut frame.atlas_rgba8, + origin, + size, + caster, + projection, + scene.smooth_passes, + ); + pages.push(ShadowPage { + origin: [origin[0] as u32, origin[1] as u32], + size: size as u32, + caster: caster.id, + projection, + }); + } + + // Page blur happens after all page rasterization and before receiver UVs + // are emitted, matching the native texture upload order. + for page in &pages { + project_receivers(&mut frame, page, scene.receivers, scene.casters); + } + Ok(frame) +} + +/// Returns the fixed native page layout, including unused slots. +#[cfg(test)] +fn native_shadow_pages() -> Vec<([u32; 2], u32)> { + (0..SHADOW_PAGE_COUNT) + .map(native_page_layout) + .map(|(origin, size)| ([origin[0] as u32, origin[1] as u32], size as u32)) + .collect() +} + +#[derive(Clone, Copy, Debug)] +struct LightContribution { + direction: [f32; 3], + strength: f32, +} + +fn select_light(caster: &ShadowCaster, lights: &[ShadowLight]) -> Option { + let center = caster.sphere.center; + let radius = caster.sphere.radius; + if caster.native_kind == 3 { + for light in lights { + if let ShadowLight::Directional { + direction, + rgb, + active: true, + flags, + } = *light + { + if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG == 0 { + continue; + } + let direction = normalize([direction[0], direction[1], direction[2] - 0.3])?; + let strength = length(rgb); + if strength > 0.0 && strength.is_finite() { + return Some(LightContribution { + direction, + strength, + }); + } + } + } + return None; + } + + let mut direction_sum = [0.0; 3]; + let mut max_strength: f32 = 0.0; + for light in lights { + match *light { + ShadowLight::Directional { + direction, + rgb, + active: true, + flags, + } if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG != 0 => { + let direction = [direction[0], direction[1], direction[2] - 0.3]; + let strength = length(rgb); + if strength > 0.0 && strength.is_finite() { + direction_sum = add(direction_sum, direction); + max_strength = max_strength.max(strength); + } + } + ShadowLight::Point { + position, + rgb, + range, + coefficients, + active: true, + flags, + } if flags & 0x8000_0000 == 0 && range >= 10.0 => { + let to_center = sub(center, position); + let distance = length(to_center); + if !(distance < range) || !distance.is_finite() { + continue; + } + let t = (range - distance) / range; + let strength = length(rgb) + * (coefficients[0] + coefficients[1] * t + coefficients[2] * t * t) + * 1.5; + if !(strength > 0.0) || !strength.is_finite() { + continue; + } + let Some(direction) = normalize(to_center) else { + continue; + }; + let Some(direction) = normalize([direction[0], direction[1], -0.3]) else { + continue; + }; + let contribution = (strength / 15.0 * range / (2.0 * radius)).min(2.0); + direction_sum = add(direction_sum, scale(direction, contribution)); + max_strength = max_strength.max(strength); + } + _ => {} + } + } + // The ordinary-object path retains the magnitude of the accumulated + // vector; native uses it again in the receiver normal term. + if length(direction_sum) <= 1.0e-6 || !length(direction_sum).is_finite() { + return None; + } + Some(LightContribution { + direction: direction_sum, + strength: max_strength, + }) +} + +fn make_projection( + caster: &ShadowCaster, + light: LightContribution, + fade: f32, +) -> Result { + let center = caster.sphere.center; + let radius = caster.sphere.radius; + let light_direction = + normalize(light.direction).ok_or(ShadowError::InvalidInput("light direction"))?; + // Kind 3 arrives unit length. Other kinds intentionally keep the + // accumulated native vector magnitude in M.Z. + let shadow_axis = scale(light.direction, -1.0); + let mut axis = caster.world_axes[0]; + let mut best = f32::INFINITY; + for candidate in caster.world_axes { + let Some(candidate) = normalize(candidate) else { + continue; + }; + let score = dot(candidate, light_direction).abs(); + if score < best { + best = score; + axis = candidate; + } + } + let mut u = normalize(cross(axis, shadow_axis)); + if u.is_none() { + for candidate in identity_axes() { + u = normalize(cross(candidate, shadow_axis)); + if u.is_some() { + break; + } + } + } + let u = u.ok_or(ShadowError::InvalidInput("world axes"))?; + let v = normalize(cross(shadow_axis, u)).ok_or(ShadowError::InvalidInput("shadow basis"))?; + let x_column = scale(v, 2.0 * radius); + let y_column = scale(u, -2.0 * radius); + let z_column = shadow_axis; + let world_from_shadow = matrix_from_columns(x_column, y_column, z_column, center); + let shadow_from_world = inverse_shadow_matrix(v, u, shadow_axis, center, radius); + Ok(ShadowProjection { + world_from_shadow, + shadow_from_world, + shadow_axis, + opacity: (fade * light.strength * 0.2).min(1.0), + upper_distance: 4.0 * radius, + lower_distance: -100.0, + light_strength: light.strength, + }) +} + +fn projected_shadow_lod( + caster: &ShadowCaster, + camera: ShadowCamera, + settings: ShadowLodSettings, +) -> u8 { + let offset = sub(camera.position, caster.sphere.center); + let distance_squared = dot(offset, offset); + let radius_squared = caster.sphere.radius * caster.sphere.radius; + let measure = if distance_squared < radius_squared { + camera.viewport_width + } else { + let tangent_distance = (distance_squared - radius_squared).max(0.0).sqrt(); + caster.sphere.radius.atan2(tangent_distance) * camera.viewport_width / camera.horizontal_fov + }; + if !measure.is_finite() { + return 8; + } + match caster.native_kind { + 4 => { + let computed = if measure < 4.0 { + 8 + } else if measure >= 70.0 { + 0 + } else { + let scaled = measure * settings.robot_detail; + if scaled < 8.0 { + 3 + } else if scaled < 30.0 { + 2 + } else { + 1 + } + }; + computed.max(settings.robot_min_lod) + } + 3 => { + let computed = if measure < 10.0 { + 8 + } else if measure >= 120.0 { + 1 + } else { + let scaled = measure * settings.building_detail; + if scaled < 40.0 { + 3 + } else if scaled < 80.0 { + 2 + } else { + 1 + } + }; + computed.max(settings.building_min_lod) + } + _ => 8, + } +} + +fn native_page_layout(index: usize) -> ([usize; 2], usize) { + // This is the physical order emitted by Terrain's page allocator, not a + // row-major packing approximation. The non-monotonic order is observable + // in the native page table and is part of the texture contract. + const LARGE: [[usize; 2]; 12] = [ + [0, 0], + [0, 64], + [64, 64], + [64, 0], + [0, 128], + [0, 192], + [64, 192], + [64, 128], + [128, 0], + [128, 64], + [192, 64], + [192, 0], + ]; + const SMALL: [[usize; 2]; 16] = [ + [128, 128], + [128, 160], + [160, 160], + [160, 128], + [128, 192], + [128, 224], + [160, 224], + [160, 192], + [192, 192], + [192, 224], + [224, 224], + [224, 192], + [192, 128], + [192, 160], + [224, 160], + [224, 128], + ]; + if index < LARGE.len() { + (LARGE[index], 64) + } else { + (SMALL[index - LARGE.len()], 32) + } +} + +/// Matches the x86 `cvtt` helper used by Ngi103F0 with the renderer's +/// default nearest-even rounding mode. `f32::round` is ties-away-from-zero, +/// so it cannot be used for the native half-pixel boundaries. +fn native_round(value: f32) -> isize { + debug_assert!(value.is_finite()); + let lower = value.floor(); + let fraction = value - lower; + let rounded = if fraction < 0.5 { + lower + } else if fraction > 0.5 { + lower + 1.0 + } else if (lower as i64) & 1 == 0 { + lower + } else { + lower + 1.0 + }; + rounded as isize +} + +fn rasterize_page( + atlas: &mut [u8], + origin: [usize; 2], + size: usize, + caster: &ShadowCaster, + projection: ShadowProjection, + smooth_passes: u32, +) { + for triangle in &caster.triangles { + let points = triangle + .positions + .map(|position| transform_point(projection.shadow_from_world, position)); + let area = cross2(sub2(points[1], points[0]), sub2(points[2], points[0])); + if !area.is_finite() || area < 0.0 { + continue; + } + + // Ngi103F0 converts the projected endpoints with the active nearest, + // ties-to-even rounding mode. The raster loop visits rows below the + // rounded upper endpoint; evaluating the edge slopes at `y + 0.5` + // reproduces the native pixel-center scanline. + let min_y = points + .iter() + .map(|point| point[1]) + .fold(f32::INFINITY, f32::min); + let max_y = points + .iter() + .map(|point| point[1]) + .fold(f32::NEG_INFINITY, f32::max); + let first_y = native_round((min_y + 0.5) * size as f32); + let last_y = native_round((max_y + 0.5) * size as f32); + let first_y = first_y.max(0); + let last_y = last_y.min(size as isize); + if first_y >= last_y { + continue; + } + for y in first_y as usize..last_y as usize { + let local_y = (y as f32 + 0.5) / size as f32 - 0.5; + let mut intersections = [0.0_f32; 3]; + let mut count = 0; + for edge in [(0usize, 1usize), (1, 2), (2, 0)] { + let a = points[edge.0]; + let b = points[edge.1]; + if ((a[1] <= local_y && b[1] > local_y) || (b[1] <= local_y && a[1] > local_y)) + && count < intersections.len() + { + let t = (local_y - a[1]) / (b[1] - a[1]); + intersections[count] = a[0] + (b[0] - a[0]) * t; + count += 1; + } + } + if count < 2 { + continue; + } + let min_x = intersections[..count] + .iter() + .copied() + .fold(f32::INFINITY, f32::min); + let max_x = intersections[..count] + .iter() + .copied() + .fold(f32::NEG_INFINITY, f32::max); + + // Ngi103F0 is also used for both X intersections. The native + // fill includes the rounded right endpoint, which matters for + // half-pixel and zero-width scanline cases. + let first = native_round((min_x + 0.5) * size as f32); + let last = native_round((max_x + 0.5) * size as f32); + let first = first.max(0); + let last = last.min(size as isize - 1); + if first > last { + continue; + } + for x in first as usize..=last as usize { + write_alpha_nibble(atlas, origin, size, x, y, 15); + } + } + } + if smooth_passes > 0 { + blur_page(atlas, origin, size, false); + } + if smooth_passes > 1 { + blur_page(atlas, origin, size, true); + } +} + +fn blur_page(atlas: &mut [u8], origin: [usize; 2], size: usize, reverse: bool) { + if size < 2 { + return; + } + + // FastProc3A368/24510 operates in place. The forward pass writes the + // top-left texel of each 2x2 window in row-major order; the last row and + // column are never visited. The optional reverse pass mirrors that + // traversal and writes the bottom-right texel, leaving the first row and + // column untouched. Native integer division always uses four samples, + // including at the page boundary. + if reverse { + for y in (1..size).rev() { + for x in (1..size).rev() { + let total = u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y - 1)) + + u32::from(read_alpha_nibble(atlas, origin, size, x, y - 1)) + + u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y)) + + u32::from(read_alpha_nibble(atlas, origin, size, x, y)); + write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8); + } + } + } else { + for y in 0..size - 1 { + for x in 0..size - 1 { + let total = u32::from(read_alpha_nibble(atlas, origin, size, x, y)) + + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y)) + + u32::from(read_alpha_nibble(atlas, origin, size, x, y + 1)) + + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y + 1)); + write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8); + } + } + } +} + +#[derive(Clone, Copy)] +struct ClipVertex { + position: [f32; 3], + uv: [f32; 2], +} + +fn project_receivers( + frame: &mut ShadowFrame, + page: &ShadowPage, + receivers: &[ShadowTriangle], + casters: &[ShadowCaster], +) { + let Some(caster) = casters.iter().find(|caster| caster.id == page.caster) else { + return; + }; + // Kind-4 small actors select the four-ray receiver path once for the + // caster. The native query descriptor is compact 0x8008, but Landscape + // expands it to full face flags 0x00200020 before filtering triangles; + // all other casters use the ordinary full 0x2000 exclusion bit. + let ray_path = caster.native_kind == 4 && caster.sphere.radius < 10.0; + let ray_fade = ray_path.then(|| four_ray_fade(page, receivers)); + for receiver in receivers { + let use_rays = ray_path && receiver.mask & SHADOW_FOUR_RAY_MASK == 0; + let use_general = !ray_path && receiver.mask & SHADOW_RECEIVER_MASK == 0; + if !use_rays && !use_general { + continue; + } + let normal = normalize(receiver.normal).unwrap_or_else(|| { + normalize(cross( + sub(receiver.positions[1], receiver.positions[0]), + sub(receiver.positions[2], receiver.positions[0]), + )) + .unwrap_or([0.0, 0.0, 0.0]) + }); + let normal_term = dot(page.projection.shadow_axis, normal); + if normal_term <= 0.0 || !normal_term.is_finite() { + continue; + } + + let plane_distance = dot(normal, receiver.positions[0]); + let corners = shadow_quad(&page.projection); + let mut projected = Vec::with_capacity(4); + for (upper, lower, uv) in corners.iter() { + let delta = sub(*lower, *upper); + if dot(normal, delta) >= SHADOW_PROJECTION_EPSILON { + projected.clear(); + break; + } + let upper_distance = dot(normal, *upper) - plane_distance; + let lower_distance = dot(normal, *lower) - plane_distance; + if upper_distance < 0.0 || lower_distance >= 0.0 { + projected.clear(); + break; + } + let denominator = upper_distance - lower_distance; + if denominator <= SHADOW_CLIP_EPSILON { + projected.clear(); + break; + } + let t = (upper_distance / denominator).clamp(0.0, 1.0); + projected.push(ClipVertex { + position: lerp(*upper, *lower, t), + uv: *uv, + }); + } + if projected.len() != 4 { + continue; + } + + // Terrain's receiver routine clips the projected four-corner quad + // against the three receiver edge planes. Keeping UV with the point + // makes the later fan exactly the native interpolated quad. + for edge in 0..3 { + let a = receiver.positions[edge]; + let b = receiver.positions[(edge + 1) % 3]; + projected = clip_polygon(projected, |vertex| { + dot(cross(sub(b, a), sub(vertex.position, a)), normal) + }); + } + projected.truncate(9); + if projected.len() < 3 { + continue; + } + let opacity = if use_rays { + (ray_fade.unwrap_or(0.0) * page.projection.light_strength * 0.2).min(1.0) + } else { + page.projection.opacity + }; + let alpha = opacity * normal_term.sqrt(); + if alpha <= 0.0 || !alpha.is_finite() { + continue; + } + let start = frame.vertices.len() as u32; + for vertex in &projected { + frame.vertices.push(ShadowVertex { + position: vertex.position, + uv: [ + (page.origin[0] as f32 + vertex.uv[0] * page.size as f32) + / SHADOW_ATLAS_WIDTH as f32, + (page.origin[1] as f32 + vertex.uv[1] * page.size as f32) + / SHADOW_ATLAS_HEIGHT as f32, + ], + alpha, + normal, + }); + } + for index in 1..projected.len() - 1 { + frame.indices.extend_from_slice(&[ + start, + start + index as u32, + start + index as u32 + 1, + ]); + } + } +} + +fn shadow_quad(projection: &ShadowProjection) -> [([f32; 3], [f32; 3], [f32; 2]); 4] { + let uv_max = SHADOW_QUAD_UV_MAX; + let corners = [[0.0, 0.0], [uv_max, 0.0], [uv_max, uv_max], [0.0, uv_max]]; + corners.map(|corner| { + let local = [corner[0] - 0.5, corner[1] - 0.5]; + let uv = corner; + ( + transform_point( + projection.world_from_shadow, + [local[0], local[1], projection.upper_distance], + ), + transform_point( + projection.world_from_shadow, + [local[0], local[1], projection.lower_distance], + ), + uv, + ) + }) +} + +/// Casts the four native shadow-quad segments against receiver query +/// triangles. A miss contributes nothing; the native average divides by the +/// number of successful ray hits rather than by four. +fn four_ray_fade(page: &ShadowPage, receivers: &[ShadowTriangle]) -> f32 { + let corners = shadow_quad(&page.projection); + let mut sum = 0.0; + let mut hits = 0_u32; + for (upper, lower, _) in corners { + let delta = sub(lower, upper); + let mut nearest = f32::INFINITY; + for receiver in receivers { + if receiver.mask & SHADOW_FOUR_RAY_MASK != 0 { + continue; + } + if let Some(parameter) = segment_triangle_parameter(upper, delta, receiver) { + let hit = add(upper, scale(delta, parameter)); + let offset = sub(hit, upper); + let distance_squared = dot(offset, offset); + nearest = nearest.min(distance_squared); + } + } + if nearest.is_finite() { + sum += (1.0 - nearest * 0.0001).clamp(0.0, 1.0); + hits += 1; + } + } + if hits == 0 { + 0.0 + } else { + sum / hits as f32 + } +} + +fn segment_triangle_parameter( + origin: [f32; 3], + delta: [f32; 3], + triangle: &ShadowTriangle, +) -> Option { + let edge1 = sub(triangle.positions[1], triangle.positions[0]); + let edge2 = sub(triangle.positions[2], triangle.positions[0]); + let p = cross(delta, edge2); + let determinant = dot(edge1, p); + if determinant.abs() <= SHADOW_CLIP_EPSILON { + return None; + } + let inverse = 1.0 / determinant; + let tvec = sub(origin, triangle.positions[0]); + let u = dot(tvec, p) * inverse; + if !(0.0..=1.0).contains(&u) { + return None; + } + let q = cross(tvec, edge1); + let v = dot(delta, q) * inverse; + if v < 0.0 || u + v > 1.0 { + return None; + } + let parameter = dot(edge2, q) * inverse; + (parameter >= 0.0 && parameter <= 1.0).then_some(parameter) +} + +fn clip_polygon(polygon: Vec, signed_distance: F) -> Vec +where + F: Fn(ClipVertex) -> f32, +{ + if polygon.is_empty() { + return polygon; + } + let mut output = Vec::with_capacity(polygon.len() + 1); + let mut previous = *polygon.last().expect("non-empty polygon"); + let mut previous_distance = signed_distance(previous); + for ¤t in &polygon { + let current_distance = signed_distance(current); + let previous_inside = previous_distance >= 0.0; + let current_inside = current_distance >= 0.0; + if previous_inside != current_inside { + let denominator = previous_distance - current_distance; + let t = if denominator.abs() > SHADOW_CLIP_EPSILON { + previous_distance / denominator + } else { + 0.0 + }; + output.push(interpolate_clip(previous, current, t)); + } + if current_inside { + output.push(current); + } + previous = current; + previous_distance = current_distance; + } + output +} + +fn interpolate_clip(a: ClipVertex, b: ClipVertex, t: f32) -> ClipVertex { + ClipVertex { + position: lerp(a.position, b.position, t), + uv: [ + a.uv[0] + (b.uv[0] - a.uv[0]) * t, + a.uv[1] + (b.uv[1] - a.uv[1]) * t, + ], + } +} + +fn validate_caster(caster: &ShadowCaster) -> Result<(), ShadowError> { + if !caster.sphere.radius.is_finite() || caster.sphere.radius <= 0.0 { + return Err(ShadowError::InvalidInput("caster radius")); + } + if !caster.sphere.center.iter().all(|value| value.is_finite()) + || !caster + .world_axes + .iter() + .flatten() + .all(|value| value.is_finite()) + { + return Err(ShadowError::InvalidInput("caster transform")); + } + for triangle in &caster.triangles { + validate_triangle(triangle, "caster")?; + } + Ok(()) +} + +fn validate_triangle(triangle: &ShadowTriangle, field: &'static str) -> Result<(), ShadowError> { + if !triangle + .positions + .iter() + .flatten() + .all(|value| value.is_finite()) + || !triangle.normal.iter().all(|value| value.is_finite()) + { + return Err(ShadowError::InvalidInput(field)); + } + Ok(()) +} + +fn validate_light(light: &ShadowLight) -> Result<(), ShadowError> { + match *light { + ShadowLight::Directional { direction, rgb, .. } + if direction.iter().all(|value| value.is_finite()) + && rgb.iter().all(|value| value.is_finite()) => + { + Ok(()) + } + ShadowLight::Point { + position, + rgb, + range, + coefficients, + .. + } if position.iter().all(|value| value.is_finite()) + && rgb.iter().all(|value| value.is_finite()) + && range.is_finite() + && coefficients.iter().all(|value| value.is_finite()) => + { + Ok(()) + } + _ => Err(ShadowError::InvalidInput("light")), + } +} + +fn write_alpha_nibble( + atlas: &mut [u8], + origin: [usize; 2], + size: usize, + x: usize, + y: usize, + nibble: u8, +) { + let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4; + if index + 3 < atlas.len() { + atlas[index] = 0; + atlas[index + 1] = 0; + atlas[index + 2] = 0; + atlas[index + 3] = nibble.saturating_mul(17); + } + let _ = size; +} + +fn read_alpha_nibble(atlas: &[u8], origin: [usize; 2], _size: usize, x: usize, y: usize) -> u8 { + let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4; + atlas.get(index + 3).copied().unwrap_or(0) / 17 +} + +fn matrix_from_columns(x: [f32; 3], y: [f32; 3], z: [f32; 3], translation: [f32; 3]) -> [f32; 16] { + [ + x[0], + y[0], + z[0], + translation[0], + x[1], + y[1], + z[1], + translation[1], + x[2], + y[2], + z[2], + translation[2], + 0.0, + 0.0, + 0.0, + 1.0, + ] +} + +fn inverse_shadow_matrix( + v: [f32; 3], + u: [f32; 3], + shadow_axis: [f32; 3], + center: [f32; 3], + radius: f32, +) -> [f32; 16] { + let x_scale = scale(v, 1.0 / (2.0 * radius)); + let y_scale = scale(u, -1.0 / (2.0 * radius)); + let z_scale = scale(shadow_axis, 1.0 / dot(shadow_axis, shadow_axis)); + [ + x_scale[0], + x_scale[1], + x_scale[2], + -dot(x_scale, center), + y_scale[0], + y_scale[1], + y_scale[2], + -dot(y_scale, center), + z_scale[0], + z_scale[1], + z_scale[2], + -dot(z_scale, center), + 0.0, + 0.0, + 0.0, + 1.0, + ] +} + +fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> [f32; 3] { + [ + matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3], + matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7], + matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11], + ] +} + +fn identity_axes() -> [[f32; 3]; 3] { + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] +} + +fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn scale(a: [f32; 3], scalar: f32) -> [f32; 3] { + [a[0] * scalar, a[1] * scalar, a[2] * scalar] +} + +fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + add(a, scale(sub(b, a), t)) +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn cross2(a: [f32; 2], b: [f32; 2]) -> f32 { + a[0] * b[1] - a[1] * b[0] +} + +fn sub2(a: [f32; 3], b: [f32; 3]) -> [f32; 2] { + [a[0] - b[0], a[1] - b[1]] +} + +fn length(a: [f32; 3]) -> f32 { + dot(a, a).sqrt() +} + +fn normalize(a: [f32; 3]) -> Option<[f32; 3]> { + let length = length(a); + (length > 1.0e-6 && length.is_finite()).then(|| scale(a, 1.0 / length)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_layout_has_twelve_large_and_sixteen_small_pages() { + let pages = native_shadow_pages(); + assert_eq!(pages.len(), 28); + assert_eq!( + pages[..12].iter().map(|(_, size)| *size).sum::(), + 12 * 64 + ); + assert!(pages[..12].iter().all(|(_, size)| *size == 64)); + assert!(pages[12..].iter().all(|(_, size)| *size == 32)); + assert_eq!( + pages.iter().map(|(origin, _)| *origin).collect::>(), + vec![ + [0, 0], + [0, 64], + [64, 64], + [64, 0], + [0, 128], + [0, 192], + [64, 192], + [64, 128], + [128, 0], + [128, 64], + [192, 64], + [192, 0], + [128, 128], + [128, 160], + [160, 160], + [160, 128], + [128, 192], + [128, 224], + [160, 224], + [160, 192], + [192, 192], + [192, 224], + [224, 224], + [224, 192], + [192, 128], + [192, 160], + [224, 160], + [224, 128], + ] + ); + } + + #[test] + fn directional_shadow_raster_and_receiver_projection_are_concrete() { + let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0); + let caster = caster(7, 2.0, 1, vec![caster_triangle]); + let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0); + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 1.0], + rgb: [1.0, 1.0, 1.0], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let frame = build_shadow_frame(ShadowScene { + lights: &[light], + casters: &[caster], + receivers: &[receiver], + fade: 1.0, + smooth_passes: 0, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("shadow frame"); + assert_eq!(frame.atlas_rgba8.len(), 256 * 256 * 4); + assert!(frame.atlas_rgba8.chunks_exact(4).any(|rgba| rgba[3] == 255)); + assert!(!frame.vertices.is_empty()); + assert_eq!(frame.indices.len() % 3, 0); + assert!(frame.vertices.iter().all(|vertex| vertex.alpha > 0.0)); + } + + #[test] + fn point_light_range_and_native_flags_gate_pages() { + let caster = caster(1, 1.0, 1, Vec::new()); + let inactive = ShadowLight::Point { + position: [0.0, 0.0, -1.0], + rgb: [1.0; 3], + range: 20.0, + coefficients: [1.0, 0.0, 0.0], + active: true, + flags: 0x8000_0000, + }; + let frame = build_shadow_frame(ShadowScene { + lights: &[inactive], + casters: &[caster], + receivers: &[], + fade: 1.0, + smooth_passes: 0, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("valid empty frame"); + assert!(frame.atlas_rgba8.iter().all(|byte| *byte == 0)); + } + + #[test] + fn ordinary_directional_uses_primary_raw_direction_and_max_strength_only() { + let caster = caster(1, 1.0, 1, Vec::new()); + let lights = [ + ShadowLight::Directional { + direction: [2.0, 0.0, 4.0], + rgb: [0.0, 0.0, 3.0], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }, + ShadowLight::Directional { + direction: [100.0, 0.0, 100.0], + rgb: [100.0; 3], + active: true, + flags: 0x1000_0000, + }, + ]; + let contribution = select_light(&caster, &lights).expect("primary directional"); + assert_eq!(contribution.direction, [2.0, 0.0, 3.7]); + assert!((contribution.strength - 3.0).abs() < 1.0e-6); + } + + #[test] + fn point_direction_overwrites_normalized_z_before_second_normalize() { + let caster = caster(1, 1.0, 1, Vec::new()); + let light = ShadowLight::Point { + position: [0.0, 0.0, -5.0], + rgb: [1.0; 3], + range: 20.0, + coefficients: [1.0, 0.0, 0.0], + active: true, + flags: 0, + }; + let contribution = select_light(&caster, &[light]).expect("point light"); + let expected_strength = 3.0_f32.sqrt() * 1.5; + let expected_contribution = expected_strength / 15.0 * 20.0 / 2.0; + assert!((contribution.direction[2] + expected_contribution).abs() < 1.0e-6); + assert!(contribution.direction[0].abs() < 1.0e-6); + assert!((contribution.strength - expected_strength).abs() < 1.0e-6); + } + + #[test] + fn four_ray_fade_averages_successes_and_zeroes_all_misses() { + let caster = caster(1, 1.0, 4, Vec::new()); + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 1.0], + rgb: [1.0; 3], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let projection = make_projection( + &caster, + select_light(&caster, &[light]).expect("light"), + 1.0, + ) + .expect("projection"); + let page = ShadowPage { + origin: [0, 0], + size: 64, + caster: 1, + projection, + }; + let partial = triangle([[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]], 0); + let fade = four_ray_fade(&page, &[partial]); + assert!( + fade > 0.9, + "three successful rays must not be divided by four" + ); + let fifty_world_units = triangle( + [ + [-100.0, -100.0, 47.2], + [100.0, -100.0, 47.2], + [0.0, 100.0, 47.2], + ], + 0, + ); + let fade_at_fifty = four_ray_fade(&page, &[fifty_world_units]); + assert!( + (fade_at_fifty - 0.75).abs() < 1.0e-6, + "fade at 50 world units: {fade_at_fifty}" + ); + let miss = triangle([[10.0, 10.0, 1.0], [11.0, 10.0, 1.0], [10.0, 11.0, 1.0]], 0); + assert_eq!(four_ray_fade(&page, &[miss]), 0.0); + let excluded = triangle( + [[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]], + SHADOW_FOUR_RAY_MASK, + ); + assert_eq!(four_ray_fade(&page, &[excluded]), 0.0); + } + + #[test] + fn ordinary_alpha_keeps_native_hdr_normal_term_after_opacity_clamp() { + let caster = caster(1, 1.0, 1, Vec::new()); + let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0); + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 10.0], + rgb: [100.0; 3], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let frame = build_shadow_frame(ShadowScene { + lights: &[light], + casters: &[caster], + receivers: &[receiver], + fade: 1.0, + smooth_passes: 0, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("HDR shadow frame"); + assert!(frame.vertices.iter().any(|vertex| vertex.alpha > 1.0)); + } + + #[test] + fn smoothing_uses_nibble_quantization() { + let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0); + let caster = caster(1, 2.0, 1, vec![caster_triangle]); + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 1.0], + rgb: [1.0; 3], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let frame = build_shadow_frame(ShadowScene { + lights: &[light], + casters: &[caster], + receivers: &[], + fade: 1.0, + smooth_passes: 2, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("smooth frame"); + assert!(frame + .atlas_rgba8 + .chunks_exact(4) + .any(|rgba| rgba[3] > 0 && rgba[3] < 255 && rgba[3] % 17 == 0)); + } + + #[test] + fn native_rounding_and_blur_preserve_asymmetric_page_edges() { + assert_eq!(native_round(1.5), 2); + assert_eq!(native_round(2.5), 2); + assert_eq!(native_round(-0.5), 0); + assert_eq!(native_round(-1.5), -2); + + let size = 4; + let origin = [0, 0]; + let mut atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4]; + for y in 0..size { + for x in 0..size { + write_alpha_nibble(&mut atlas, origin, size, x, y, (y * size + x) as u8); + } + } + blur_page(&mut atlas, origin, size, false); + let expected_forward = [ + [2, 3, 4, 3], + [6, 7, 8, 7], + [10, 11, 12, 11], + [12, 13, 14, 15], + ]; + for (y, row) in expected_forward.iter().enumerate() { + for (x, expected) in row.iter().enumerate() { + assert_eq!( + read_alpha_nibble(&atlas, origin, size, x, y), + *expected, + "forward blur at ({x},{y})" + ); + } + } + + let mut reverse_atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4]; + for y in 0..size { + for x in 0..size { + write_alpha_nibble(&mut reverse_atlas, origin, size, x, y, (y * size + x) as u8); + } + } + blur_page(&mut reverse_atlas, origin, size, true); + let expected_reverse = [[0, 1, 2, 3], [4, 2, 3, 4], [8, 6, 7, 8], [12, 10, 11, 12]]; + for (y, row) in expected_reverse.iter().enumerate() { + for (x, expected) in row.iter().enumerate() { + assert_eq!( + read_alpha_nibble(&reverse_atlas, origin, size, x, y), + *expected, + "reverse blur at ({x},{y})" + ); + } + } + } + + #[test] + fn native_lod_uses_projected_measure_and_reuses_page_slots() { + let mut building = caster(41, 1.0, 3, Vec::new()); + building.sphere.center = [0.0, 0.0, 1.05]; + let camera = ShadowCamera { + position: [0.0, 0.0, 0.0], + viewport_width: 100.0, + horizontal_fov: std::f32::consts::FRAC_PI_2, + }; + let close_lod = projected_shadow_lod(&building, camera, ShadowLodSettings::default()); + assert_eq!(close_lod, 1); + + let mut cache = ShadowPageCache::default(); + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 1.0], + rgb: [1.0; 3], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let first = cache + .build(ShadowScene { + lights: &[light], + casters: &[building.clone()], + receivers: &[], + fade: 1.0, + smooth_passes: 0, + camera: Some(camera), + lod: ShadowLodSettings::default(), + }) + .expect("first cached frame"); + assert_eq!( + first.atlas_rgba8.len(), + SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4 + ); + let slot = cache.page_slot(building.id).expect("cached page"); + let second = cache + .build(ShadowScene { + lights: &[light], + casters: &[building], + receivers: &[], + fade: 1.0, + smooth_passes: 0, + camera: Some(camera), + lod: ShadowLodSettings::default(), + }) + .expect("reused cached frame"); + assert_eq!(cache.page_slot(41), Some(slot)); + assert_eq!(second.atlas_rgba8.len(), first.atlas_rgba8.len()); + } + + #[test] + fn native_lod_rejects_small_and_unknown_shadow_objects() { + let camera = ShadowCamera { + position: [0.0, 0.0, 0.0], + viewport_width: 100.0, + horizontal_fov: std::f32::consts::FRAC_PI_2, + }; + let mut robot = caster(1, 1.0, 4, Vec::new()); + robot.sphere.center = [0.0, 0.0, 100.0]; + assert_eq!( + projected_shadow_lod(&robot, camera, ShadowLodSettings::default()), + 8 + ); + let unknown = caster(2, 100.0, 10, Vec::new()); + assert_eq!( + projected_shadow_lod(&unknown, camera, ShadowLodSettings::default()), + 8 + ); + } + + #[test] + fn page_cache_keeps_current_frame_slots_unique_and_rejects_stale_overflow() { + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 1.0], + rgb: [1.0; 3], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let casters = (0..40) + .map(|id| caster(id, 1.0, 3, Vec::new())) + .collect::>(); + let mut cache = ShadowPageCache::default(); + + for (start, expected_used) in [(0, 20), (20, 16), (20, 16)] { + let selected = casters[start..start + 20].to_vec(); + let _frame = cache + .build(ShadowScene { + lights: &[light], + casters: &selected, + receivers: &[], + fade: 1.0, + smooth_passes: 0, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("cached multi-frame shadow build"); + + let used = cache + .frame_used + .iter() + .enumerate() + .filter_map(|(index, used)| used.then_some(index)) + .collect::>(); + assert_eq!(used.len(), expected_used); + let mut owners = used + .iter() + .map(|&index| cache.slots[index].expect("used slot owner").caster) + .collect::>(); + owners.sort_unstable(); + owners.dedup(); + assert_eq!(owners.len(), used.len(), "two current casters share a page"); + + for id in 0..40 { + let owner_count = cache + .slots + .iter() + .flatten() + .filter(|slot| slot.caster == id) + .count(); + assert!(owner_count <= 1, "caster {id} owns duplicate page slots"); + } + } + + // The second frame has more new casters than the aged fallback range + // can accept. Rejected records must remain unowned rather than being + // assigned an occupied stateless index. + assert!(cache.page_slot(36).is_none()); + assert!(cache.page_slot(37).is_none()); + assert!(cache.page_slot(38).is_none()); + assert!(cache.page_slot(39).is_none()); + } + + #[test] + fn receiver_masks_are_full_exclusions() { + let caster = caster(1, 1.0, 4, Vec::new()); + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 1.0], + rgb: [1.0; 3], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let ground = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0); + let excluded = triangle( + [[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], + SHADOW_FOUR_RAY_MASK, + ); + let accepted = build_shadow_frame(ShadowScene { + lights: &[light], + casters: &[caster.clone()], + receivers: &[ground], + fade: 1.0, + smooth_passes: 0, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("unmasked ground receiver"); + let filtered = build_shadow_frame(ShadowScene { + lights: &[light], + casters: &[caster], + receivers: &[ground, excluded], + fade: 1.0, + smooth_passes: 0, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("masked receiver"); + assert_eq!(filtered.vertices.len(), accepted.vertices.len()); + assert_eq!(filtered.indices.len(), accepted.indices.len()); + } + + fn triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle { + ShadowTriangle { + positions, + normal: normalize(cross( + sub(positions[1], positions[0]), + sub(positions[2], positions[0]), + )) + .unwrap_or([0.0; 3]), + mask, + } + } + + fn caster( + id: u32, + radius: f32, + native_kind: u32, + triangles: Vec, + ) -> ShadowCaster { + ShadowCaster { + id, + world_axes: identity_axes(), + sphere: ShadowSphere { + center: [0.0; 3], + radius, + }, + native_kind, + triangles, + } + } +} diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs new file mode 100644 index 0000000..f41e920 --- /dev/null +++ b/crates/fparkan-fx/src/sky.rs @@ -0,0 +1,2079 @@ +//! CPU-side sky dome, sky materials, fog, and sun output. +//! +//! The original `CSky` object is a finite dome rather than an equirectangular +//! skybox. This module keeps that geometry and the interpolated +//! `sky.ske`/`sky.wea` data independent of any graphics backend. A renderer +//! can upload [`SkyMesh`] once, update its vertex colors, and resolve the +//! material names in [`SkyMaterials`] through its normal resource path. + +use crate::atmosphere::{AtmosphereFrame, PackedColor, SkySample, SunBirth, SunSample}; +use std::fmt; + +/// Number of rows used by the original environment `sky.wea` table. +pub const SKY_MATERIAL_COUNT: usize = 9; + +/// Texture-coordinate scales used by the three original sky vertex stages. +/// +/// The values are the constants at PE `65E94`, `65E98`, and `65E9C` in the +/// original renderer. They are applied to `position.x / radius` and +/// `position.y / radius`. +pub const SKY_UV_SCALES: [f32; 3] = [1.0, 15.0, 3.0]; + +/// Material names observed in the AutoDemo `sky.wea` table. +/// +/// Missions are allowed to replace these names. Code should use the rows +/// returned by [`SkyMaterials::parse`] when resolving a real mission. +pub const OBSERVED_SKY_MATERIAL_NAMES: [&str; SKY_MATERIAL_COUNT] = [ + "ENV_NEBULA_0", + "ENV_STARS", + "ENV_CLOUDS", + "ENV_SUN_3", + "ENV_MOON", + "ENV_FLARE_00", + "ENV_FLARE_01", + "SNOWFLAKE", + "RAIN_DROP", +]; + +const TAU: f32 = std::f32::consts::TAU; +// These are the values written by Terrain's atmosphere-manager constructor: +// 0x6581C = 10000, and 0x6580C = 3.1415901184 is halved twice before the +// CSky constructor receives it. +const NATIVE_PI: f32 = 3.1415901184; +const DEFAULT_HEIGHT: f32 = 10_000.0; +const DEFAULT_THETA_MAX: f32 = NATIVE_PI * 0.25; +const DEFAULT_Z_SCALE: f32 = 1.0; +const DEFAULT_AZIMUTH_COUNT: usize = 16; +const DEFAULT_RINGS: usize = 5; +const FOG_DISTANCE_SCALE: f32 = 700.0; +const MAX_GEOMETRY_VERTICES: usize = 1_000_000; +// CSky::Render (481C5) uses these data constants when it converts the camera +// yaw to a four-color, ninety-degree horizon sector. +const NATIVE_GRADIENT_SEGMENT_DEGREES: i64 = 90; +const NATIVE_GRADIENT_FRACTION_SCALE: f32 = f32::from_bits(0x3C36_0B61); +const NATIVE_GRADIENT_ZERO: f32 = 0.0; +const NATIVE_GRADIENT_ONE: f32 = 1.0; +const NATIVE_GRADIENT_THREE: f32 = 3.0; +const NATIVE_GRADIENT_SHORT_SCALE: f32 = 0.8; +const NATIVE_GRADIENT_LONG_SCALE: f32 = 0.1; +const NATIVE_GRADIENT_LONG_OFFSET: f32 = 0.7; + +/// Configuration passed to the original `CSky` dome constructor. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkyGeometryConfig { + /// Dome height before the z scale is applied. + pub height: f32, + /// Maximum polar angle in radians. + pub theta_max: f32, + /// Vertical scale applied to the generated positions. + pub z_scale: f32, + /// Number of azimuth sectors around the dome. + pub azimuth_count: usize, + /// Number of vertices in each azimuth sector after the zenith vertex. + pub rings: usize, +} + +impl SkyGeometryConfig { + /// Creates a geometry configuration. + #[must_use] + pub const fn new( + height: f32, + theta_max: f32, + z_scale: f32, + azimuth_count: usize, + rings: usize, + ) -> Self { + Self { + height, + theta_max, + z_scale, + azimuth_count, + rings, + } + } + + /// Returns the radius used by the original constructor. + #[must_use] + pub fn radius(self) -> f32 { + let half_theta = self.theta_max * 0.5; + self.height * 0.5 / half_theta.sin().powi(2) + } + + /// Builds a colored sky mesh and validates all counts and floating point + /// inputs. + pub fn mesh(self, sample: &SkySample) -> Result { + self.try_mesh(sample) + } + + /// Builds a colored sky mesh and validates all counts and floating point + /// inputs. + pub fn try_mesh(self, sample: &SkySample) -> Result { + let mut mesh = self.try_topology()?; + mesh.update_colors(sample); + Ok(mesh) + } + + /// Builds the immutable dome topology. + /// + /// Vertex colors are initialized to the packed zero value and are filled + /// by [`SkyMesh::update_colors`] when the first atmosphere sample arrives. + /// Keeping topology construction separate lets a renderer upload the + /// position/normal/UV/index buffers once. + pub fn try_topology(self) -> Result { + validate_geometry(self)?; + let vertex_count = self + .azimuth_count + .checked_mul(self.rings) + .and_then(|count| count.checked_add(1)) + .ok_or(SkyGeometryError::CountOverflow)?; + if vertex_count > MAX_GEOMETRY_VERTICES { + return Err(SkyGeometryError::TooManyVertices { vertex_count }); + } + let triangle_count = self + .azimuth_count + .checked_mul( + self.rings + .checked_mul(2) + .and_then(|count| count.checked_sub(1)) + .ok_or(SkyGeometryError::CountOverflow)?, + ) + .ok_or(SkyGeometryError::CountOverflow)?; + let index_count = triangle_count + .checked_mul(3) + .ok_or(SkyGeometryError::CountOverflow)?; + let radius = self.radius(); + if !radius.is_finite() || radius <= 0.0 { + return Err(SkyGeometryError::NonFiniteRadius); + } + + let mut vertices = Vec::with_capacity(vertex_count); + vertices.push(SkyVertex { + position: [0.0, 0.0, self.height * self.z_scale], + normal: [0, 0, 127], + uv: [[0.0, 0.0]; 3], + color: PackedColor::default(), + }); + + for azimuth in 0..self.azimuth_count { + let phi = azimuth as f32 / self.azimuth_count as f32 * TAU; + let sin_phi = phi.sin(); + let cos_phi = phi.cos(); + for ring in 0..self.rings { + let theta = (ring + 1) as f32 / self.rings as f32 * self.theta_max; + let sin_theta = theta.sin(); + let cos_theta = theta.cos(); + let position = [ + radius * sin_theta * sin_phi, + radius * sin_theta * cos_phi, + (radius * cos_theta + self.height - radius) * self.z_scale, + ]; + let normal = [ + normal_byte(sin_theta * sin_phi), + normal_byte(sin_theta * cos_phi), + normal_byte(cos_theta), + ]; + let uv = std::array::from_fn(|stage| { + [ + position[0] / radius * SKY_UV_SCALES[stage], + position[1] / radius * SKY_UV_SCALES[stage], + ] + }); + vertices.push(SkyVertex { + position, + normal, + uv, + color: PackedColor::default(), + }); + } + } + + let mut indices = Vec::with_capacity(index_count); + for azimuth in 0..self.azimuth_count { + let base = 1 + azimuth * self.rings; + let next = 1 + ((azimuth + 1) % self.azimuth_count) * self.rings; + indices.push(0); + indices.extend_from_slice(&[ + u32::try_from(base).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(next).map_err(|_| SkyGeometryError::IndexOverflow)?, + ]); + for ring in 0..self.rings - 1 { + let a = base + ring; + let b = base + ring + 1; + let c = next + ring; + let d = next + ring + 1; + indices.extend_from_slice(&[ + u32::try_from(a).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(d).map_err(|_| SkyGeometryError::IndexOverflow)?, + ]); + } + } + + Ok(SkyMesh { + config: self, + radius, + vertices, + indices, + }) + } +} + +impl Default for SkyGeometryConfig { + fn default() -> Self { + Self::new( + DEFAULT_HEIGHT, + DEFAULT_THETA_MAX, + DEFAULT_Z_SCALE, + DEFAULT_AZIMUTH_COUNT, + DEFAULT_RINGS, + ) + } +} + +fn validate_geometry(config: SkyGeometryConfig) -> Result<(), SkyGeometryError> { + if !config.height.is_finite() || config.height <= 0.0 { + return Err(SkyGeometryError::InvalidHeight(config.height)); + } + if !config.theta_max.is_finite() || config.theta_max <= 0.0 || config.theta_max >= TAU { + return Err(SkyGeometryError::InvalidTheta(config.theta_max)); + } + if !config.z_scale.is_finite() { + return Err(SkyGeometryError::InvalidZScale(config.z_scale)); + } + if config.azimuth_count == 0 || config.rings == 0 { + return Err(SkyGeometryError::ZeroCount); + } + Ok(()) +} + +fn normal_byte(component: f32) -> i8 { + // Terrain's native sky constructor writes signed normal components with + // x87 nearest-even rounding and a 127 scale. Keep the signed type here; + // a renderer that uploads the legacy byte stream can cast it to u8. + (component * 127.0).round_ties_even().clamp(-128.0, 127.0) as i8 +} + +fn color_for_vertex( + sample: &SkySample, + ring: usize, + azimuth: usize, + azimuth_count: usize, +) -> PackedColor { + match ring { + 0 => sample.colors[12], + 1 => belt_color(sample, 8, azimuth, azimuth_count), + 2 => belt_color(sample, 4, azimuth, azimuth_count), + _ => belt_color(sample, 0, azimuth, azimuth_count), + } +} + +fn belt_color( + sample: &SkySample, + offset: usize, + azimuth: usize, + azimuth_count: usize, +) -> PackedColor { + let group_width = azimuth_count.saturating_add(3) / 4; + if group_width == 0 { + return sample.colors[offset]; + } + let anchor = (azimuth / group_width).min(3); + let next = (anchor + 1) & 3; + let factor = (azimuth % group_width) as f32 / group_width as f32; + sample.colors[offset + anchor].lerp(sample.colors[offset + next], factor) +} + +/// One CPU vertex in the original sky buffer layout. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkyVertex { + /// World-space position. + pub position: [f32; 3], + /// Signed 8-bit normal components written by the original constructor. + pub normal: [i8; 3], + /// Three texture-coordinate stages, in original stage order. + pub uv: [[f32; 2]; 3], + /// Current ARGB vertex color. + pub color: PackedColor, +} + +impl SkyVertex { + /// Returns the packed color as normalized RGBA. + #[must_use] + pub fn color_rgba(self) -> [f32; 4] { + self.color.rgba() + } + + /// Decodes the native signed normal bytes to normalized components. + #[must_use] + pub fn normal_vector(self) -> [f32; 3] { + self.normal.map(|component| f32::from(component) / 127.0) + } +} + +/// Generated sky dome geometry and indices. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyMesh { + /// Configuration used to generate this mesh. + pub config: SkyGeometryConfig, + /// Radius from the original constructor formula. + pub radius: f32, + /// Zenith followed by azimuth-major ring vertices. + pub vertices: Vec, + /// Triangle-list indices: one fan triangle and `rings - 1` quads per + /// azimuth sector. + pub indices: Vec, +} + +impl SkyMesh { + /// Number of triangles in the mesh. + #[must_use] + pub fn triangle_count(&self) -> usize { + self.indices.len() / 3 + } + + /// Updates only the dynamic vertex colors for an atmosphere sample. + /// + /// The vertex order is fixed by [`SkyGeometryConfig::try_topology`], so + /// this operation never reallocates the topology or index buffer. The + /// renderer can upload the changed color fields in place. + pub fn update_colors(&mut self, sample: &SkySample) { + let Some(zenith) = self.vertices.first_mut() else { + return; + }; + zenith.color = sample.colors[12]; + for azimuth in 0..self.config.azimuth_count { + let base = 1 + azimuth * self.config.rings; + for ring in 0..self.config.rings { + let Some(vertex) = self.vertices.get_mut(base + ring) else { + return; + }; + vertex.color = color_for_vertex(sample, ring, azimuth, self.config.azimuth_count); + } + } + } +} + +/// Errors found while constructing a sky dome. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SkyGeometryError { + /// Height must be finite and positive. + InvalidHeight(f32), + /// Polar angle must be finite and in `(0, TAU)`. + InvalidTheta(f32), + /// Vertical scale must be finite. + InvalidZScale(f32), + /// A sector or ring count was zero. + ZeroCount, + /// A count calculation overflowed. + CountOverflow, + /// Mesh allocation would exceed the bounded CPU contract. + TooManyVertices { vertex_count: usize }, + /// The radius formula did not produce a finite positive value. + NonFiniteRadius, + /// An index could not fit in the renderer-facing u32 index type. + IndexOverflow, +} + +impl fmt::Display for SkyGeometryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHeight(value) => { + write!(f, "sky height must be finite and positive: {value}") + } + Self::InvalidTheta(value) => write!(f, "sky theta_max must be in (0, TAU): {value}"), + Self::InvalidZScale(value) => write!(f, "sky z_scale must be finite: {value}"), + Self::ZeroCount => write!(f, "sky azimuth and ring counts must be non-zero"), + Self::CountOverflow => write!(f, "sky geometry count overflow"), + Self::TooManyVertices { vertex_count } => { + write!(f, "sky geometry has too many vertices: {vertex_count}") + } + Self::NonFiniteRadius => write!(f, "sky geometry radius is not finite and positive"), + Self::IndexOverflow => write!(f, "sky geometry index does not fit in u32"), + } + } +} + +impl std::error::Error for SkyGeometryError {} + +/// One row from a `sky.wea` material table. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkyMaterialRow { + /// Legacy numeric id preserved from the source row. + pub legacy_id: i32, + /// Material lookup name. + pub name: String, +} + +/// Parsed dynamic material names from `sky.wea`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkyMaterials { + /// Main material rows in file order. + pub rows: Vec, + /// Optional lightmap rows after the `LIGHTMAPS` section. + pub lightmaps: Vec, +} + +impl SkyMaterials { + /// Parses a text `sky.wea` payload. + pub fn parse(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut lines = text.lines().enumerate(); + let (count_line, count_text) = lines + .find_map(|(line, text)| (!text.trim().is_empty()).then_some((line, text.trim()))) + .ok_or(SkyWeaError::Empty)?; + let count = count_text + .parse::() + .map_err(|_| SkyWeaError::InvalidCount { + line: count_line, + text: count_text.to_string(), + })?; + if count == 0 { + return Err(SkyWeaError::ZeroCount); + } + + let mut rows = Vec::with_capacity(count); + for index in 0..count { + let Some((line, text)) = lines.next() else { + return Err(SkyWeaError::MissingRow { index, count }); + }; + let text = text.trim(); + if text.is_empty() { + return Err(SkyWeaError::InvalidRow { + index, + line, + text: text.to_string(), + }); + } + rows.push(parse_material_row(index, line, text)?); + } + + let mut lightmaps = Vec::new(); + let mut rest = lines.filter_map(|(line, text)| { + let trimmed = text.trim(); + (!trimmed.is_empty()).then_some((line, trimmed)) + }); + if let Some((line, marker)) = rest.next() { + if !marker.eq_ignore_ascii_case("LIGHTMAPS") { + return Err(SkyWeaError::UnexpectedTrailingLine { + line, + text: marker.to_string(), + }); + } + let Some((count_line, count_text)) = rest.next() else { + return Err(SkyWeaError::InvalidLightmapCount { + line, + text: String::new(), + }); + }; + let lightmap_count = + count_text + .parse::() + .map_err(|_| SkyWeaError::InvalidLightmapCount { + line: count_line, + text: count_text.to_string(), + })?; + lightmaps.reserve(lightmap_count); + for index in 0..lightmap_count { + let Some((row_line, row_text)) = rest.next() else { + return Err(SkyWeaError::MissingLightmapRow { + index, + count: lightmap_count, + }); + }; + lightmaps.push(parse_material_row(index, row_line, row_text)?); + } + if let Some((extra_line, extra_text)) = rest.next() { + return Err(SkyWeaError::UnexpectedTrailingLine { + line: extra_line, + text: extra_text.to_string(), + }); + } + } + + Ok(Self { rows, lightmaps }) + } + + /// Creates a table from caller-provided names, assigning positional ids. + #[must_use] + pub fn from_names(names: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + rows: names + .into_iter() + .enumerate() + .map(|(index, name)| SkyMaterialRow { + legacy_id: index as i32, + name: name.into(), + }) + .collect(), + lightmaps: Vec::new(), + } + } + + /// Returns the positional material row. + #[must_use] + pub fn row(&self, index: usize) -> Option<&SkyMaterialRow> { + self.rows.get(index) + } + + /// Returns the material name at a positional row. + #[must_use] + pub fn material_name(&self, index: usize) -> Option<&str> { + self.row(index).map(|row| row.name.as_str()) + } + + /// Returns all main material names in file order. + #[must_use] + pub fn names(&self) -> Vec<&str> { + self.rows.iter().map(|row| row.name.as_str()).collect() + } +} + +impl From> for SkyMaterials { + fn from(names: Vec) -> Self { + Self::from_names(names) + } +} + +impl Default for SkyMaterials { + fn default() -> Self { + Self::from_names(OBSERVED_SKY_MATERIAL_NAMES) + } +} + +fn parse_material_row( + index: usize, + line: usize, + text: &str, +) -> Result { + let mut parts = text.splitn(2, char::is_whitespace); + let legacy_text = parts.next().unwrap_or_default(); + let name = parts.next().unwrap_or_default().trim(); + let legacy_id = legacy_text + .parse::() + .map_err(|_| SkyWeaError::InvalidRow { + index, + line, + text: text.to_string(), + })?; + if name.is_empty() { + return Err(SkyWeaError::InvalidRow { + index, + line, + text: text.to_string(), + }); + } + Ok(SkyMaterialRow { + legacy_id, + name: name.to_string(), + }) +} + +/// Errors returned while parsing `sky.wea`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SkyWeaError { + /// Payload has no non-empty lines. + Empty, + /// Main row count is malformed. + InvalidCount { line: usize, text: String }, + /// Main table declared no rows. + ZeroCount, + /// A main row is missing. + MissingRow { index: usize, count: usize }, + /// A main or lightmap row is malformed. + InvalidRow { + index: usize, + line: usize, + text: String, + }, + /// Lightmap count is malformed. + InvalidLightmapCount { line: usize, text: String }, + /// A lightmap row is missing. + MissingLightmapRow { index: usize, count: usize }, + /// Non-empty data followed the main rows without a known section marker. + UnexpectedTrailingLine { line: usize, text: String }, +} + +impl fmt::Display for SkyWeaError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "sky.wea payload is empty"), + Self::InvalidCount { line, text } => { + write!(f, "invalid sky.wea count at line {line}: {text}") + } + Self::ZeroCount => write!(f, "sky.wea material count must be positive"), + Self::MissingRow { index, count } => { + write!(f, "missing sky.wea row {index} of {count}") + } + Self::InvalidRow { index, line, text } => { + write!(f, "invalid sky.wea row {index} at line {line}: {text}") + } + Self::InvalidLightmapCount { line, text } => { + write!(f, "invalid sky.wea lightmap count at line {line}: {text}") + } + Self::MissingLightmapRow { index, count } => { + write!(f, "missing sky.wea lightmap row {index} of {count}") + } + Self::UnexpectedTrailingLine { line, text } => { + write!(f, "unexpected sky.wea line {line}: {text}") + } + } + } +} + +impl std::error::Error for SkyWeaError {} + +/// Parses a `sky.wea` payload without requiring callers to name the parser +/// type. +pub fn parse_sky_wea(bytes: &[u8]) -> Result { + SkyMaterials::parse(bytes) +} + +/// The nine positional material roles used by the original sky renderer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkyLayerKind { + /// Nebula/background row zero. + Nebula, + /// Stars row one. + Stars, + /// Clouds row two. + Clouds, + /// Sun sprite row three. + Sun, + /// Moon sprite row four. + Moon, + /// First flare row five. + Flare0, + /// Second flare row six. + Flare1, + /// Snow row seven. + Snow, + /// Rain row eight. + Rain, + /// A mission-specific row after the known roles. + Other(usize), +} + +impl SkyLayerKind { + /// Maps a positional WEA row to its known role. + #[must_use] + pub const fn from_row(row: usize) -> Self { + match row { + 0 => Self::Nebula, + 1 => Self::Stars, + 2 => Self::Clouds, + 3 => Self::Sun, + 4 => Self::Moon, + 5 => Self::Flare0, + 6 => Self::Flare1, + 7 => Self::Snow, + 8 => Self::Rain, + other => Self::Other(other), + } + } +} + +/// Native draw order for the dome and its full-screen gradient. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkyPassKind { + /// Untextured full-screen gradient quad. + ScreenGradient, + /// Row zero nebula with row one stars bound as its secondary texture. + NebulaStars, + /// Untextured colored gradient over the dome. + DomeGradient, + /// Row two cloud dome translated below the camera. + Clouds, +} + +/// Renderer contract for one native sky pass. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyPassFrame<'a> { + /// Pass order/role. + pub kind: SkyPassKind, + /// Primary positional WEA material row, if textured. + pub material_row: Option, + /// Secondary positional WEA row for the stars texture. + pub secondary_material_row: Option, + /// Primary material name. + pub material: Option<&'a str>, + /// Secondary material name. + pub secondary_material: Option<&'a str>, + /// Primary vertex UV stage. + pub uv_stage: Option, + /// Secondary vertex UV stage. + pub secondary_uv_stage: Option, + /// World-space Z offset relative to camera translation. + pub translation_z: f32, + /// Native blend mode of the pass. + pub blend: SkyBlendMode, + /// Native clouds directional RGB, when the pass uses it. + pub material_color: Option<[f32; 3]>, +} + +/// Lighting-dependent colors produced by the native screen-gradient update. +/// +/// `color` is the packed ARGB value sent to the full-screen gradient and to +/// the last dome ring. `horizon_floor` is the independently computed RGB +/// floor from the light-manager glare delta; native CSky clamps every stored +/// dome palette color up to these three channels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkyGradientFrame { + /// Packed ARGB screen-gradient and last-ring color. + pub color: PackedColor, + /// Lower bound applied to dome palette RGB channels, normalized to 0..1. + pub horizon_floor: [f32; 3], +} + +impl SkyGradientFrame { + /// Raises a packed dome color to the native lighting-dependent RGB floor. + #[must_use] + pub fn clamp_dome_color(self, color: PackedColor) -> PackedColor { + let rgba = color.rgba(); + PackedColor::from_rgba([ + rgba[0].max(self.horizon_floor[0]), + rgba[1].max(self.horizon_floor[1]), + rgba[2].max(self.horizon_floor[2]), + rgba[3], + ]) + } +} + +/// Per-row state supplied alongside [`SkyMesh`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyLayerFrame<'a> { + /// Positional row in `sky.wea`. + pub row: usize, + /// Interpreted role for the row. + pub kind: SkyLayerKind, + /// Dynamic mission material name, if that row exists. + pub material: Option<&'a str>, + /// Whether the row is currently visible according to atmosphere state. + pub active: bool, + /// Vertex UV stage used by the native sky material pass. + pub uv_stage: usize, + /// Native UV scale for this layer's stage. + pub uv_scale: f32, + /// Normalized row intensity where the atmosphere supplies one. + pub intensity: f32, + /// Monotonic elapsed time for material UV animation. + pub phase_seconds: f32, +} + +/// Blend operation used by a directional sky sprite pass. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkyBlendMode { + /// Use the sprite's alpha channel over the current sky. + Alpha, + /// Add the sprite contribution to the current sky. + Additive, +} + +/// The fixed four-corner UV order used by native sun, moon, and flare quads. +/// +/// `CSun` leaves a half-percent border around the source texture. The order +/// is left-top, left-bottom, right-bottom, right-top in the native quad. +pub const SKY_SPRITE_UV: [[f32; 2]; 4] = [ + [0.005, 0.005], + [0.005, 0.995], + [0.995, 0.995], + [0.995, 0.005], +]; + +/// Positional flare rows emitted by the original sun object. +pub const SKY_FLARE_ROWS: [usize; 12] = [5, 5, 6, 6, 5, 6, 5, 5, 6, 5, 5, 5]; + +const SKY_FLARE_SIZES: [f32; 12] = [0.2, 0.3, 0.2, 0.1, 0.1, 0.3, 0.3, 0.7, 0.4, 1.0, 0.3, 0.2]; +const SKY_FLARE_OFFSETS: [f32; 12] = [ + 1.2, 0.7, 0.5, 0.2, 0.0, -0.2, -0.3, -0.5, -0.6, -0.8, -1.0, -1.1, +]; +const SKY_FLARE_COLORS: [u32; 12] = [ + 0xFFB0_90A3, + 0xFF5A_58BB, + 0x9630_BE52, + 0x96C9_3432, + 0xFF30_BE52, + 0x9696_9664, + 0xFFB0_90A3, + 0xFF7C_6BC9, + 0x9630_6452, + 0xFF0B_17B9, + 0xFFB6_26B1, + 0xFF7C_C5C9, +]; + +const GLARE_COS_15: f32 = 0.965_925_8; +const GLARE_COS_30: f32 = 0.866_025_4; +const GLARE_COS_60: f32 = 0.5; +const SUN_SPRITE_DISTANCE: f32 = 0.325; + +/// Renderer-facing state for one directional sun, moon or flare sprite. +/// +/// The CPU side provides the native material row, direction, sampled RGB and +/// unit-quad UVs. A renderer projects the direction through its active camera +/// and applies the material's size/alpha state. Flare rows additionally need a +/// camera visibility or occlusion test before drawing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkySpriteFrame<'a> { + /// Positional role in `sky.wea`. + pub kind: SkyLayerKind, + /// Positional material row. + pub row: usize, + /// Dynamic mission material name. + pub material: Option<&'a str>, + /// Whether the source celestial interval is active. + pub active: bool, + /// Direction in world space. + pub direction: [f32; 3], + /// Native packed sprite RGB (`SunSample::packed[0]`). + pub color: [f32; 3], + /// Native packed sprite alpha (`SunSample::packed[0]`). + pub alpha: f32, + /// The two native half-size sample values used by sun/moon quads. + pub size_values: [f32; 2], + /// Sampled intensity. + pub intensity: f32, + /// Unit quad texture coordinates in native corner order. + pub uv: [[f32; 2]; 4], + /// Blend operation for the sprite pass. + pub blend: SkyBlendMode, + /// Flares require camera-space visibility testing. + pub requires_occlusion: bool, +} + +/// One native directional source submitted for a celestial object. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DirectionalLightFrame { + /// False means the light slot must be removed from the light manager. + pub active: bool, + /// Direction used by the native light manager. + pub direction: [f32; 3], + /// RGB value passed to the native light manager without another intensity + /// multiplication. + pub color: [f32; 3], +} + +impl DirectionalLightFrame { + const INACTIVE: Self = Self { + active: false, + direction: [0.0; 3], + color: [0.0; 3], + }; +} + +/// Camera data needed by the native screen-space sun and flare path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunVisibility { + /// Projected sun center in viewport pixels. + pub projected_sun: [f32; 2], + /// Viewport width and height in pixels. + pub viewport: [f32; 2], + /// Camera forward vector in world space. + pub view_forward: [f32; 3], + /// Result of the renderer's world-ray visibility query. + pub unoccluded: bool, +} + +/// One screen-space flare generated from the native flare tables. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FlareQuad { + /// Native positional material row (5 or 6). + pub row: usize, + /// Whether this quad should be submitted this frame. + pub active: bool, + /// Final RGBA color after the packed sun alpha multiplication. + pub color: [f32; 4], + /// Center in viewport pixels. + pub center_pixels: [f32; 2], + /// Equal X/Y half-size in viewport pixels. + pub half_size_pixels: f32, + /// Native UV order for the quad. + pub uv: [[f32; 2]; 4], +} + +/// Result of the native sun glare/flare calculation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunOptics { + /// RGB submitted to the primary directional light after the glare boost. + pub primary_color: [f32; 3], + /// Squared 15-degree cone amount before the horizon factor. + pub glare_amount: f32, + /// Twelve fixed-topology flare slots. + pub flares: [FlareQuad; 12], +} + +/// Errors returned when a camera projection cannot supply native sprite data. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SkyProjectionError { + /// Viewport dimensions must be finite and positive. + InvalidViewport([f32; 2]), + /// Horizontal FOV must be finite and in `(0, pi)`. + InvalidHorizontalFov(f32), + /// A camera forward vector could not be normalized. + InvalidViewForward([f32; 3]), +} + +impl fmt::Display for SkyProjectionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidViewport(value) => write!(f, "invalid sky viewport: {value:?}"), + Self::InvalidHorizontalFov(value) => { + write!(f, "invalid sky horizontal FOV: {value}") + } + Self::InvalidViewForward(value) => { + write!(f, "invalid sky view forward vector: {value:?}") + } + } + } +} + +impl std::error::Error for SkyProjectionError {} + +/// Errors returned while updating a persistent sky owner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SkyUpdateError { + /// An externally supplied atmosphere sample contains a non-finite value. + NonFiniteAtmosphere, + /// The persistent mesh no longer matches its immutable configuration. + TopologyChanged, +} + +impl fmt::Display for SkyUpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonFiniteAtmosphere => { + write!(f, "sky atmosphere sample contains a non-finite value") + } + Self::TopologyChanged => write!(f, "sky topology changed after construction"), + } + } +} + +impl std::error::Error for SkyUpdateError {} + +/// Fog values sent by the original `CSky` update. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FogFrame { + /// Normalized RGB fog color from `SkySample::packed[1]`. + pub color: [f32; 3], + /// Native global lighting floor from the same sampled RGB state. + /// + /// `CShade` merges this vector with the material lighting accumulator by + /// taking the per-channel maximum before the shader receives the result. + pub lighting_floor: [f32; 3], + /// Fog start distance (`sky.values[0] * 700`). + pub start: f32, + /// Fog end distance (`sky.values[1] * 700`). + pub end: f32, +} + +impl FogFrame { + /// Converts an interpolated sky sample to fog parameters. + #[must_use] + pub fn from_sample(sample: &SkySample) -> Self { + let start = finite_or_zero(sample.values[0]) * FOG_DISTANCE_SCALE; + let end = finite_or_zero(sample.values[1]) * FOG_DISTANCE_SCALE; + let rgba = sample.packed[1].rgba(); + let color = [rgba[0], rgba[1], rgba[2]]; + Self { + color, + lighting_floor: color, + start, + end, + } + } +} + +/// Converts an interpolated sky sample to fog parameters. +#[must_use] +pub fn fog_frame(sample: &SkySample) -> FogFrame { + FogFrame::from_sample(sample) +} + +/// Birth and orbit settings for a sun object. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunConfig { + /// Time at which the stateless sun sample starts, in seconds. + pub start_seconds: f32, + /// Lifetime used by the original `(now - start) / life` age calculation. + pub lifetime_seconds: f32, + /// Two integer birth angles from the sun birth payload, in degrees. + pub initial_angles_degrees: [f32; 2], +} + +impl SunConfig { + /// Creates sun settings. + #[must_use] + pub const fn new( + start_seconds: f32, + lifetime_seconds: f32, + initial_angles_degrees: [f32; 2], + ) -> Self { + Self { + start_seconds, + lifetime_seconds, + initial_angles_degrees, + } + } + + fn angles_radians(self) -> [f32; 2] { + [ + finite_or_zero(self.initial_angles_degrees[0]).to_radians(), + finite_or_zero(self.initial_angles_degrees[1]).to_radians(), + ] + } + + /// Converts the native birth payload emitted by the atmosphere sampler. + #[must_use] + pub const fn from_birth(birth: SunBirth) -> Self { + Self::new( + birth.start_seconds, + birth.lifetime_seconds, + birth.initial_angles_degrees, + ) + } +} + +impl Default for SunConfig { + fn default() -> Self { + Self::new(0.0, 1.0, [0.0; 2]) + } +} + +/// A renderer-facing sun sample. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunFrame { + /// Whether the latest SunStart/SunStop interval is active. + pub active: bool, + /// Final world-space direction after the original birth-angle matrix. + pub direction: [f32; 3], + /// Intermediate orbit direction before the birth-angle matrix. + pub orbit_direction: [f32; 3], + /// RGB light color from `SunSample::rgb`. + pub color: [f32; 3], + /// RGBA color stored in the native sprite descriptor. + pub sprite_color: [f32; 4], + /// RGB color of the opposite directional light from packed parameter 1. + pub secondary_color: [f32; 3], + /// The two native screen-quad size values from the SKE tail. + pub size_values: [f32; 2], + /// Material row selected by the native birth dispatcher. + pub material_row: Option, + /// Atmosphere intensity from trailing key parameter two. + pub intensity: f32, + /// Clamped normalized lifecycle age. + pub age: f32, + /// Untransformed orbit angle in radians. + pub theta: f32, +} + +impl SunFrame { + /// Converts a sampled sun and stateless clock into a frame. + #[must_use] + pub fn from_sample(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Self { + sun_frame(sample, now_seconds, config) + } + + /// Returns an inactive frame without inventing lifecycle timing. + #[must_use] + pub fn inactive(sample: &SunSample) -> Self { + let sprite_color = sample.packed[0].rgba(); + let secondary_color = sample.packed[1].rgba(); + Self { + active: false, + direction: [0.0; 3], + orbit_direction: [0.0; 3], + color: sample.rgb.map(finite_or_zero), + sprite_color, + secondary_color: [secondary_color[0], secondary_color[1], secondary_color[2]], + size_values: sample.values.map(finite_or_zero), + material_row: None, + intensity: finite_or_zero(sample.intensity).max(0.0), + age: 0.0, + theta: 0.0, + } + } + + /// Direction from the camera toward the native celestial geometry. + /// + /// The light manager stores the opposite vector, so the screen/world + /// sprite path negates [`Self::direction`]. + #[must_use] + pub fn geometry_direction(self) -> [f32; 3] { + self.direction.map(|component| -component) + } + + /// Places the native celestial object at half the camera far plane. + /// This is `camera_translation - light_direction * far / 2`, with the + /// geometry direction exposed by [`Self::geometry_direction`]. + #[must_use] + pub fn world_center(self, camera_translation: [f32; 3], camera_far: f32) -> [f32; 3] { + let distance = finite_or_zero(camera_far) * 0.5; + let direction = self.geometry_direction(); + std::array::from_fn(|index| camera_translation[index] + direction[index] * distance) + } + + /// Returns primary and opposite directional lights in native order. + #[must_use] + pub fn directional_lights(self) -> [DirectionalLightFrame; 2] { + if !self.active { + return [DirectionalLightFrame::INACTIVE; 2]; + } + [ + DirectionalLightFrame { + active: true, + direction: self.direction, + color: self.color, + }, + DirectionalLightFrame { + active: true, + direction: self.direction.map(|component| -component), + color: self.secondary_color, + }, + ] + } +} + +/// 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 +/// 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)] +/// ``` +/// +/// where `x` and `z` are the two non-zero orbit components. +#[must_use] +pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> SunFrame { + let color = sample.rgb.map(finite_or_zero); + let sprite_color = sample.packed[0].rgba(); + let secondary = sample.packed[1].rgba(); + let intensity = finite_or_zero(sample.intensity).max(0.0); + if !sample.active { + return SunFrame::inactive(sample); + } + + let now_seconds = finite_or_zero(now_seconds); + let start_seconds = finite_or_zero(config.start_seconds); + let lifetime = finite_or_zero(config.lifetime_seconds); + let age = if lifetime > 0.0 { + ((now_seconds - start_seconds) / lifetime).clamp(0.0, 1.0) + } else { + 1.0 + }; + let theta = (age * 1.2 - 0.1) * std::f32::consts::PI; + let orbit_direction = [theta.cos(), 0.0, -theta.sin()]; + let [angle_a, angle_b] = config.angles_radians(); + let (sin_a, cos_a) = angle_a.sin_cos(); + let (sin_b, cos_b) = angle_b.sin_cos(); + 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, + ]); + SunFrame { + active: true, + direction, + orbit_direction, + color, + sprite_color, + secondary_color: [secondary[0], secondary[1], secondary[2]], + size_values: sample.values.map(finite_or_zero), + material_row: sample.birth.map(|birth| birth.material_row), + intensity, + age, + theta, + } +} + +/// Converts a sampled celestial object using the birth payload generated by +/// the atmosphere dispatcher. +/// +/// The schedule sampler carries the absolute start time, lifetime, angles and +/// material row for the active interval. Keeping that payload in the sample +/// means a renderer does not need a stateful wrapper or a caller-provided +/// placeholder configuration. +#[must_use] +pub fn celestial_frame(sample: &SunSample, now_seconds: f32) -> SunFrame { + sample + .birth + .map(|birth| sun_frame(sample, now_seconds, SunConfig::from_birth(birth))) + .unwrap_or_else(|| SunFrame::inactive(sample)) +} + +/// Computes the native half-size of a sun or moon screen quad in pixels. +/// +/// The SKE tail values are the first two fields of `SunSample::values`; they +/// are deliberately not read from the material descriptor. The renderer +/// supplies the real viewport and horizontal FOV for the current swapchain. +pub fn sprite_half_size_pixels( + values: [f32; 2], + viewport: [f32; 2], + horizontal_fov: f32, +) -> Result<[f32; 2], SkyProjectionError> { + validate_viewport(viewport)?; + if !horizontal_fov.is_finite() + || horizontal_fov <= 0.0 + || horizontal_fov >= std::f32::consts::PI + { + return Err(SkyProjectionError::InvalidHorizontalFov(horizontal_fov)); + } + let scale = viewport[0] / horizontal_fov * SUN_SPRITE_DISTANCE * 0.5; + Ok([ + scale * finite_or_zero(values[0]), + scale * finite_or_zero(values[1]), + ]) +} + +/// Computes native glare and flare quads for the current camera. +pub fn sun_optics( + sun: SunFrame, + visibility: SunVisibility, +) -> Result { + validate_viewport(visibility.viewport)?; + let view_forward = normalize3_checked(visibility.view_forward)?; + let light_direction = sun.direction; + let to_sun = light_direction.map(|component| -component); + let cone = dot3(view_forward, to_sun); + let cone_amount = ((cone - GLARE_COS_15) / (1.0 - GLARE_COS_15)).max(0.0); + let glare_amount = cone_amount * cone_amount; + let projected_inside = visibility.projected_sun[0].is_finite() + && visibility.projected_sun[1].is_finite() + && visibility.projected_sun[0] >= 0.0 + && visibility.projected_sun[0] <= visibility.viewport[0] + && visibility.projected_sun[1] >= 0.0 + && visibility.projected_sun[1] <= visibility.viewport[1]; + let primary_length = length3(sun.color); + let visible = sun.active && projected_inside && visibility.unoccluded && primary_length > 1.1; + + let mut primary_color = sun.color; + if visible { + let height = + ((-light_direction[2] - GLARE_COS_60) / (GLARE_COS_30 - GLARE_COS_60)).clamp(0.0, 1.0); + let boost = 1.0 + 4.0 * sun.sprite_color[3].clamp(0.0, 1.0) * height * glare_amount; + primary_color = primary_color.map(|component| component * boost); + } + + let flare_active = visible && glare_amount <= 0.1; + let viewport_center = [visibility.viewport[0] * 0.5, visibility.viewport[1] * 0.5]; + let flares = std::array::from_fn(|index| { + let base = PackedColor(SKY_FLARE_COLORS[index]); + let rgba = base.rgba(); + let base_alpha = ((SKY_FLARE_COLORS[index] >> 24) & 0xFF) as f32; + let alpha = (base_alpha * sun.sprite_color[3].clamp(0.0, 1.0)).round_ties_even() / 255.0; + let offset = SKY_FLARE_OFFSETS[index]; + let center = [ + viewport_center[0] + (visibility.projected_sun[0] - viewport_center[0]) * offset, + viewport_center[1] + (visibility.projected_sun[1] - viewport_center[1]) * offset, + ]; + FlareQuad { + row: SKY_FLARE_ROWS[index], + active: flare_active, + color: [rgba[0], rgba[1], rgba[2], alpha], + center_pixels: center, + half_size_pixels: visibility.viewport[0] * SKY_FLARE_SIZES[index] / 8.0, + uv: SKY_SPRITE_UV, + } + }); + Ok(SunOptics { + primary_color, + glare_amount, + flares, + }) +} + +fn validate_viewport(viewport: [f32; 2]) -> Result<(), SkyProjectionError> { + if viewport[0].is_finite() && viewport[1].is_finite() && viewport[0] > 0.0 && viewport[1] > 0.0 + { + Ok(()) + } else { + Err(SkyProjectionError::InvalidViewport(viewport)) + } +} + +fn normalize3_checked(vector: [f32; 3]) -> Result<[f32; 3], SkyProjectionError> { + let length = length3(vector); + if length.is_finite() && length > 0.0 { + Ok(vector.map(|component| component / length)) + } else { + Err(SkyProjectionError::InvalidViewForward(vector)) + } +} + +fn length3(vector: [f32; 3]) -> f32 { + (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt() +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + +/// Samples the native full-screen sky gradient and lighting-dependent horizon +/// floor for one view. +/// +/// The original `CSky::Render` code at `481C5` converts `(yaw + pi)` to +/// degrees with the x87 nearest-even conversion, chooses one of four +/// ninety-degree sectors, and interpolates the corresponding two entries of +/// `SkySample::colors[0..4]`. The later `483A3..485EE` block derives a +/// non-negative light-manager glare RGB delta and raises the stored dome +/// palette to its piecewise RGB floor. The renderer supplies +/// `glare_rgb_delta = max(current_light_rgb - base_light_rgb, 0)` componentwise; +/// it is not a camera or projection vector. +#[must_use] +pub fn screen_gradient_frame( + sample: &SkySample, + yaw_radians: f32, + glare_rgb_delta: [f32; 3], +) -> SkyGradientFrame { + let yaw_radians = finite_or_zero(yaw_radians); + let phase_degrees = ((yaw_radians + NATIVE_PI) * (180.0 / NATIVE_PI)).round_ties_even(); + let phase_degrees = phase_degrees as i64; + let segment = phase_degrees.div_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES); + let remainder = phase_degrees.rem_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES); + let palette_index = (segment - 2).rem_euclid(4) as usize; + let next_palette_index = (palette_index + 1) & 3; + let palette_fraction = remainder as f32 * NATIVE_GRADIENT_FRACTION_SCALE; + let color = interpolate_gradient_color( + sample.colors[palette_index], + sample.colors[next_palette_index], + palette_fraction, + ); + let glare_rgb_delta = glare_rgb_delta.map(|component| finite_or_zero(component).max(0.0)); + let horizon_floor = glare_rgb_delta.map(native_horizon_floor_component); + SkyGradientFrame { + color, + horizon_floor, + } +} + +fn interpolate_gradient_color( + first: PackedColor, + second: PackedColor, + fraction: f32, +) -> PackedColor { + let channel = |shift: u32| { + let a = ((first.raw() >> shift) & 0xFF) as f32; + let b = ((second.raw() >> shift) & 0xFF) as f32; + (a + (b - a) * fraction).clamp(0.0, 255.0).round_ties_even() as u32 + }; + PackedColor(0xFF00_0000 | (channel(16) << 16) | (channel(8) << 8) | channel(0)) +} + +fn native_horizon_floor_component(delta: f32) -> f32 { + let mapped = if delta <= NATIVE_GRADIENT_ONE { + delta * NATIVE_GRADIENT_SHORT_SCALE + } else if delta <= NATIVE_GRADIENT_THREE { + delta * NATIVE_GRADIENT_LONG_SCALE + NATIVE_GRADIENT_LONG_OFFSET + } else { + NATIVE_GRADIENT_ONE + }; + // Keep the normalized API bounded even if a caller supplies a malformed + // negative or non-finite value after the input sanitization above. + mapped.clamp(NATIVE_GRADIENT_ZERO, NATIVE_GRADIENT_ONE) +} + +/// Complete CPU output for one sky frame. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyFrame<'a> { + /// Interpolated atmosphere sky payload retained for renderer extensions. + pub sky: SkySample, + /// Sun and light-manager state. + pub sun: SunFrame, + /// Independent moon object state from the moon SunStart/SunStop interval. + pub moon: SunFrame, + /// Primary and opposite light for the sun, followed by the moon pair. + pub directional_lights: [DirectionalLightFrame; 4], + /// Native directional sprite data for sun, moon and both flares. + pub sprites: [SkySpriteFrame<'a>; 4], + /// Native sky draw order. The renderer submits these four passes as a + /// stable topology and changes only sampled uniforms/material dynamics. + pub passes: [SkyPassFrame<'a>; 4], + /// Fog state. + pub fog: FogFrame, + /// Native global lighting floor copied from the sampled sky state. + pub lighting_floor: [f32; 3], + /// Per-row cloud/star/sprite state. + pub layers: Vec>, + /// Clock value supplied by the caller. + pub time_seconds: f32, +} + +impl<'a> SkyFrame<'a> { + /// Builds a frame from one sampled atmosphere snapshot. + #[must_use] + pub fn from_atmosphere( + atmosphere: &AtmosphereFrame, + materials: &'a SkyMaterials, + now_seconds: f32, + ) -> SkyFrame<'a> { + let sky = atmosphere.sky.clone(); + let sun = celestial_frame(&atmosphere.sun, now_seconds); + let moon = celestial_frame(&atmosphere.moon, now_seconds); + let time_seconds = finite_or_zero(now_seconds); + let fog = fog_frame(&sky); + let passes = build_passes(materials, &sky); + Self { + sky, + sun, + moon, + directional_lights: celestial_lights(&sun, &moon), + sprites: build_sprites(materials, &sun, &moon), + passes, + lighting_floor: fog.lighting_floor, + fog, + layers: build_layers(materials, &sun, atmosphere, time_seconds), + time_seconds, + } + } + + /// Computes camera-dependent sun glare and the fixed twelve flare slots. + pub fn sun_optics(&self, visibility: SunVisibility) -> Result { + sun_optics(self.sun, visibility) + } + + /// Computes the lighting-dependent native screen-gradient and horizon floor. + #[must_use] + pub fn screen_gradient(&self, yaw_radians: f32, glare_rgb_delta: [f32; 3]) -> SkyGradientFrame { + screen_gradient_frame(&self.sky, yaw_radians, glare_rgb_delta) + } +} + +/// Stateful CPU sky owner suitable for a renderer's per-frame update. +#[derive(Clone, Debug)] +pub struct SkySystem { + /// Dome geometry configuration. + pub geometry: SkyGeometryConfig, + /// Mission material rows. + pub materials: SkyMaterials, + /// Static dome topology with mutable per-frame colors. + mesh: SkyMesh, +} + +impl SkySystem { + /// Creates a sky system. + pub fn new( + geometry: SkyGeometryConfig, + materials: SkyMaterials, + ) -> Result { + Ok(Self { + geometry, + materials, + mesh: geometry.try_topology()?, + }) + } + + /// Returns the persistent dome mesh. + #[must_use] + pub const fn mesh(&self) -> &SkyMesh { + &self.mesh + } + + /// Updates only dynamic colors and frame state on the persistent topology. + pub fn update( + &mut self, + atmosphere: &AtmosphereFrame, + now_seconds: f32, + ) -> Result, SkyUpdateError> { + validate_atmosphere(atmosphere)?; + let expected_vertices = self + .geometry + .azimuth_count + .checked_mul(self.geometry.rings) + .and_then(|count| count.checked_add(1)); + let expected_indices = self + .geometry + .azimuth_count + .checked_mul(self.geometry.rings.saturating_mul(2).saturating_sub(1)) + .and_then(|count| count.checked_mul(3)); + if expected_vertices != Some(self.mesh.vertices.len()) + || expected_indices != Some(self.mesh.indices.len()) + { + return Err(SkyUpdateError::TopologyChanged); + } + let sky = atmosphere.sky.clone(); + self.mesh.update_colors(&sky); + let sun = celestial_frame(&atmosphere.sun, now_seconds); + let moon = celestial_frame(&atmosphere.moon, now_seconds); + let time_seconds = finite_or_zero(now_seconds); + let fog = fog_frame(&sky); + let passes = build_passes(&self.materials, &sky); + Ok(SkyFrame { + sky, + sun, + moon, + directional_lights: celestial_lights(&sun, &moon), + sprites: build_sprites(&self.materials, &sun, &moon), + passes, + lighting_floor: fog.lighting_floor, + fog, + layers: build_layers(&self.materials, &sun, atmosphere, time_seconds), + time_seconds, + }) + } +} + +fn build_sprites<'a>( + materials: &'a SkyMaterials, + sun: &SunFrame, + moon: &SunFrame, +) -> [SkySpriteFrame<'a>; 4] { + [ + sprite_frame( + materials, + SkyLayerKind::Sun, + 3, + sun, + false, + SkyBlendMode::Alpha, + ), + sprite_frame( + materials, + SkyLayerKind::Moon, + 4, + moon, + false, + SkyBlendMode::Alpha, + ), + sprite_frame( + materials, + SkyLayerKind::Flare0, + 5, + sun, + true, + SkyBlendMode::Additive, + ), + sprite_frame( + materials, + SkyLayerKind::Flare1, + 6, + sun, + true, + SkyBlendMode::Additive, + ), + ] +} + +fn build_passes<'a>(materials: &'a SkyMaterials, sky: &SkySample) -> [SkyPassFrame<'a>; 4] { + [ + SkyPassFrame { + kind: SkyPassKind::ScreenGradient, + material_row: None, + secondary_material_row: None, + material: None, + secondary_material: None, + uv_stage: None, + secondary_uv_stage: None, + translation_z: 0.0, + blend: SkyBlendMode::Alpha, + material_color: None, + }, + SkyPassFrame { + kind: SkyPassKind::NebulaStars, + material_row: Some(0), + secondary_material_row: Some(1), + material: materials.material_name(0), + secondary_material: materials.material_name(1), + uv_stage: Some(0), + secondary_uv_stage: Some(1), + translation_z: 0.0, + blend: SkyBlendMode::Alpha, + material_color: None, + }, + SkyPassFrame { + kind: SkyPassKind::DomeGradient, + material_row: None, + secondary_material_row: None, + material: None, + secondary_material: None, + uv_stage: None, + secondary_uv_stage: None, + translation_z: 0.0, + blend: SkyBlendMode::Alpha, + material_color: None, + }, + SkyPassFrame { + kind: SkyPassKind::Clouds, + material_row: Some(2), + secondary_material_row: None, + material: materials.material_name(2), + secondary_material: None, + uv_stage: Some(2), + secondary_uv_stage: None, + translation_z: -5000.0, + blend: SkyBlendMode::Alpha, + material_color: { + let rgba = sky.packed[0].rgba(); + Some([rgba[0], rgba[1], rgba[2]]) + }, + }, + ] +} + +fn sprite_frame<'a>( + materials: &'a SkyMaterials, + kind: SkyLayerKind, + row: usize, + source: &SunFrame, + requires_occlusion: bool, + blend: SkyBlendMode, +) -> SkySpriteFrame<'a> { + SkySpriteFrame { + kind, + row, + material: materials.material_name(row), + active: source.active, + direction: source.geometry_direction(), + color: [ + source.sprite_color[0], + source.sprite_color[1], + source.sprite_color[2], + ], + alpha: source.sprite_color[3], + size_values: source.size_values, + intensity: source.intensity, + uv: SKY_SPRITE_UV, + blend, + requires_occlusion, + } +} + +fn build_layers<'a>( + materials: &'a SkyMaterials, + sun: &SunFrame, + atmosphere: &AtmosphereFrame, + phase_seconds: f32, +) -> Vec> { + let count = materials.rows.len().max(SKY_MATERIAL_COUNT); + (0..count) + .map(|row| { + let kind = SkyLayerKind::from_row(row); + let (active, intensity, uv_stage) = match kind { + SkyLayerKind::Nebula => (true, 1.0, 0), + SkyLayerKind::Stars => (true, 1.0, 1), + SkyLayerKind::Clouds => (true, 1.0, 2), + SkyLayerKind::Sun | SkyLayerKind::Flare0 | SkyLayerKind::Flare1 => { + (sun.active, sun.intensity, 0) + } + SkyLayerKind::Moon => (atmosphere.moon.active, atmosphere.moon.intensity, 0), + SkyLayerKind::Snow => (atmosphere.snow.active, atmosphere.snow.intensity, 0), + SkyLayerKind::Rain => (atmosphere.rain.active, atmosphere.rain.intensity, 0), + SkyLayerKind::Other(_) => (true, 1.0, 0), + }; + SkyLayerFrame { + row, + kind, + material: materials.material_name(row), + active, + uv_stage, + uv_scale: SKY_UV_SCALES[uv_stage], + intensity: finite_or_zero(intensity).max(0.0), + phase_seconds, + } + }) + .collect() +} + +fn celestial_lights(sun: &SunFrame, moon: &SunFrame) -> [DirectionalLightFrame; 4] { + let sun_lights = sun.directional_lights(); + let moon_lights = moon.directional_lights(); + [sun_lights[0], sun_lights[1], moon_lights[0], moon_lights[1]] +} + +fn validate_atmosphere(atmosphere: &AtmosphereFrame) -> Result<(), SkyUpdateError> { + let finite = |values: &[f32]| values.iter().all(|value| value.is_finite()); + if finite(&atmosphere.sky.values) + && finite(&atmosphere.sun.rgb) + && finite(&atmosphere.sun.values) + && atmosphere.sun.intensity.is_finite() + && finite(&atmosphere.moon.rgb) + && finite(&atmosphere.moon.values) + && atmosphere.moon.intensity.is_finite() + && atmosphere.rain.intensity.is_finite() + && atmosphere.snow.intensity.is_finite() + && atmosphere.lightning.intensity.is_finite() + && atmosphere.real_seconds.is_finite() + && atmosphere.track_seconds.is_finite() + && atmosphere.day_seconds.is_finite() + { + Ok(()) + } else { + Err(SkyUpdateError::NonFiniteAtmosphere) + } +} + +fn finite_or_zero(value: f32) -> f32 { + if value.is_finite() { + value + } else { + 0.0 + } +} + +fn normalize3(vector: [f32; 3]) -> [f32; 3] { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + if length.is_finite() && length > 0.0 { + vector.map(|component| component / length) + } else { + [0.0; 3] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atmosphere::{AtmosphereFrame, WeatherSample}; + + fn sample() -> SkySample { + let colors = std::array::from_fn(|index| PackedColor(0xFF00_0000 | index as u32)); + SkySample { + colors, + values: [1.0, 2.0], + packed: [PackedColor(0), PackedColor(0xFF11_2233)], + } + } + + fn sun(active: bool) -> SunSample { + SunSample { + active, + rgb: [0.5, 0.25, 0.125], + values: [0.0; 2], + packed: [PackedColor::default(); 2], + intensity: 0.5, + birth: None, + } + } + + fn atmosphere(active: bool) -> AtmosphereFrame { + AtmosphereFrame { + real_seconds: 0.0, + track_index: 0, + track_seconds: 0.0, + day_seconds: 0.0, + sky: sample(), + sun: sun(active), + moon: sun(false), + rain: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + snow: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + lightning: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + } + } + + #[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"); + assert_eq!(mesh.vertices.len(), 13); + assert_eq!(mesh.indices.len(), 4 * (6 * 3 - 3)); + assert_eq!(mesh.indices[0..3], [0, 1, 4]); + assert_eq!(mesh.indices[3..9], [1, 2, 4, 4, 2, 5]); + assert_eq!(mesh.vertices[0].position, [0.0, 0.0, 20.0]); + assert!((mesh.vertices[1].position[0]).abs() < 1.0e-5); + assert!((mesh.vertices[1].position[1] - 5.0).abs() < 1.0e-5); + assert!((mesh.vertices[1].position[2] - 17.320_508).abs() < 1.0e-5); + assert!((mesh.vertices[1].uv[1][1] - 7.5).abs() < 1.0e-5); + assert_eq!(mesh.vertices[0].normal, [0, 0, 127]); + assert_eq!(mesh.vertices[0].normal_vector(), [0.0, 0.0, 1.0]); + } + + #[test] + fn normals_use_signed_127_nearest_even_encoding() { + assert_eq!(normal_byte(0.5), 64); + assert_eq!(normal_byte(-0.5), -64); + assert_eq!(normal_byte(-1.0), -127); + } + + #[test] + fn colors_use_zenith_and_three_interpolated_quadrant_belts() { + let mut sky = sample(); + sky.colors[12] = PackedColor(0xFFAA_BBCC); + sky.colors[8] = PackedColor(0xFF00_0000); + sky.colors[9] = PackedColor(0xFF00_FF00); + sky.colors[10] = PackedColor(0xFF00_00FF); + sky.colors[11] = PackedColor(0xFFFF_FFFF); + let config = SkyGeometryConfig::new(10.0, 1.0, 1.0, 12, 4); + let mesh = config.try_mesh(&sky).expect("valid mesh"); + assert_eq!(mesh.vertices[0].color.raw(), 0xFFAA_BBCC); + assert_eq!(mesh.vertices[1].color.raw(), 0xFFAA_BBCC); + // First belt, azimuth one: one third from colors 8 to 9. + assert_eq!(mesh.vertices[6].color.raw(), PackedColor(0xFF00_5500).raw()); + // The next belt starts at the fourth ring, and the last belt is used + // for every ring after the three original color bands. + let ring_two = 1 + 2; + let ring_three = 1 + 3; + assert_eq!(mesh.vertices[ring_two].color.raw(), sky.colors[4].raw()); + assert_eq!(mesh.vertices[ring_three].color.raw(), sky.colors[0].raw()); + } + + #[test] + fn wea_parser_keeps_dynamic_names_and_optional_lightmaps() { + let table = SkyMaterials::parse( + b"2\r\n7 custom_snow\r\n8 custom_rain\r\n\r\nLIGHTMAPS\r\n1\r\n0 lm\r\n", + ) + .expect("sky.wea"); + assert_eq!(table.material_name(0), Some("custom_snow")); + assert_eq!(table.rows[1].legacy_id, 8); + assert_eq!(table.lightmaps[0].name, "lm"); + } + + #[test] + fn fog_uses_original_scale_and_packed_rgb() { + let frame = fog_frame(&sample()); + assert_eq!(frame.start, 700.0); + assert_eq!(frame.end, 1_400.0); + assert_eq!( + frame.color, + [ + 0x11 as f32 / 255.0, + 0x22 as f32 / 255.0, + 0x33 as f32 / 255.0 + ] + ); + } + + #[test] + fn screen_gradient_uses_native_yaw_and_lighting_floor() { + let mut sky = sample(); + sky.colors[0] = PackedColor(0xFF00_0000); + sky.colors[1] = PackedColor(0xFF00_FF00); + sky.colors[2] = PackedColor(0xFF00_00FF); + sky.colors[3] = PackedColor(0xFFFF_0000); + + let at_sector_start = screen_gradient_frame(&sky, 0.0, [0.0; 3]); + assert_eq!(at_sector_start.color, sky.colors[0]); + + let halfway = screen_gradient_frame(&sky, std::f32::consts::FRAC_PI_4, [0.5, 2.0, 4.0]); + assert_eq!(halfway.color, PackedColor(0xFF00_8000)); + assert_eq!(halfway.horizon_floor, [0.4, 0.9, 1.0]); + assert_eq!( + halfway.clamp_dome_color(PackedColor(0xFF10_2030)), + PackedColor(0xFF66_E6_FF) + ); + } + + #[test] + fn lighting_floor_maps_native_boundaries() { + for (delta, expected) in [(0.5, 0.4), (1.0, 0.8), (2.0, 0.9), (3.0, 1.0), (4.0, 1.0)] { + let frame = screen_gradient_frame(&sample(), 0.0, [delta; 3]); + assert_eq!(frame.horizon_floor, [expected; 3], "delta={delta}"); + } + } + + #[test] + fn sun_orientation_applies_birth_matrix_after_orbit_vector() { + 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[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[2].abs() < 1.0e-5); + } + + #[test] + fn native_birth_keeps_the_real_start_time() { + assert!(!celestial_frame(&sun(false), 100.0).active); + let birth = SunBirth { + start_seconds: 90.0, + lifetime_seconds: 20.0, + initial_angles_degrees: [0.0; 2], + material_row: 3, + }; + let mut active = sun(true); + active.birth = Some(birth); + let first = celestial_frame(&active, 100.0); + let later = celestial_frame(&active, 105.0); + assert!((first.age - 0.5).abs() < 1.0e-5); + assert!((later.age - 0.75).abs() < 1.0e-5); + assert!(!celestial_frame(&sun(false), 106.0).active); + } + + #[test] + fn frame_exposes_dynamic_layers_and_moon_direction() { + let atmosphere = atmosphere(true); + let materials = SkyMaterials::from_names(["nebula", "stars", "clouds", "sun", "moon"]); + let frame = SkyFrame::from_atmosphere(&atmosphere, &materials, 0.0); + assert_eq!(frame.layers[3].material.as_deref(), Some("sun")); + assert_eq!(frame.layers[8].material, None); + assert_eq!(frame.moon.direction, [0.0; 3]); + assert_eq!(frame.sprites[0].material, Some("sun")); + } + + #[test] + fn system_reuses_topology_and_updates_only_colors() { + let materials = SkyMaterials::from_names(["nebula", "stars", "clouds"]); + let mut system = SkySystem::new(SkyGeometryConfig::default(), materials).expect("geometry"); + let vertex_ptr = system.mesh().vertices.as_ptr(); + let index_ptr = system.mesh().indices.as_ptr(); + let positions = system + .mesh() + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(); + + let mut atmosphere = atmosphere(true); + atmosphere.sky.colors[12] = PackedColor(0xFF12_3456); + let (lighting_floor, fog_color, sprites_have_native_uv) = { + let frame = system.update(&atmosphere, 10.0).expect("sky update"); + ( + frame.lighting_floor, + frame.fog.color, + frame + .sprites + .iter() + .all(|sprite| sprite.uv == SKY_SPRITE_UV), + ) + }; + + assert_eq!(system.mesh().vertices.as_ptr(), vertex_ptr); + assert_eq!(system.mesh().indices.as_ptr(), index_ptr); + assert_eq!( + system + .mesh() + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(), + positions + ); + assert_eq!(system.mesh().vertices[0].color, PackedColor(0xFF12_3456)); + assert_eq!(lighting_floor, fog_color); + assert!(sprites_have_native_uv); + } + + #[test] + fn native_sprite_and_light_contract_uses_packed_secondary_values() { + let mut source = sun(true); + source.packed = [PackedColor(0x8011_2233), PackedColor(0xFF44_5566)]; + source.values = [2.0, 3.0]; + let frame = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0])); + assert_eq!( + frame.sprite_color, + [ + 0x11 as f32 / 255.0, + 0x22 as f32 / 255.0, + 0x33 as f32 / 255.0, + 0x80 as f32 / 255.0, + ] + ); + let lights = frame.directional_lights(); + assert_eq!(lights[0].color, frame.color); + assert_eq!(lights[1].direction, frame.direction.map(|value| -value)); + assert_eq!( + lights[1].color, + [ + 0x44 as f32 / 255.0, + 0x55 as f32 / 255.0, + 0x66 as f32 / 255.0, + ] + ); + assert_eq!( + frame.geometry_direction(), + frame.direction.map(|value| -value) + ); + let center = frame.world_center([1.0, 2.0, 3.0], 100.0); + assert!((center[0] - 1.0).abs() < 1.0e-4); + assert!((center[1] - 2.0).abs() < 1.0e-4); + assert!((center[2] - 53.0).abs() < 1.0e-4); + assert_eq!( + sprite_half_size_pixels(frame.size_values, [1000.0, 500.0], 2.0).expect("projection"), + [162.5, 243.75] + ); + } + + #[test] + fn glare_boost_and_flares_follow_native_visibility_gates() { + let mut source = sun(true); + source.rgb = [1.0, 1.0, 1.0]; + source.packed[0] = PackedColor(0xFF11_2233); + let sun = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0])); + let visibility = SunVisibility { + projected_sun: [50.0, 50.0], + viewport: [100.0, 100.0], + view_forward: sun.geometry_direction(), + unoccluded: true, + }; + let glare = sun_optics(sun, visibility).expect("optics"); + assert_eq!(glare.glare_amount, 1.0); + assert!(glare.primary_color[0] > sun.color[0]); + assert!(glare.flares.iter().all(|flare| !flare.active)); + + let mut visibility = visibility; + visibility.view_forward = sun.direction; + let flare = sun_optics(sun, visibility).expect("optics"); + assert!(flare.glare_amount < 0.1); + assert!(flare.flares.iter().all(|quad| quad.active)); + assert_eq!(flare.flares[0].row, 5); + assert_eq!(flare.flares[0].uv, SKY_SPRITE_UV); + assert_eq!(flare.flares[0].center_pixels, [50.0, 50.0]); + } + + #[test] + fn native_passes_preserve_nebula_stars_gradient_cloud_order() { + let materials = SkyMaterials::from_names([ + "nebula", "stars", "clouds", "sun", "moon", "flare0", "flare1", "snow", "rain", + ]); + let sky = sample(); + let frame = SkyFrame::from_atmosphere(&atmosphere(false), &materials, 0.0); + assert_eq!(frame.passes.len(), 4); + assert_eq!(frame.passes[0].kind, SkyPassKind::ScreenGradient); + assert_eq!(frame.passes[1].kind, SkyPassKind::NebulaStars); + assert_eq!(frame.passes[1].material_row, Some(0)); + assert_eq!(frame.passes[1].secondary_material_row, Some(1)); + assert_eq!(frame.passes[1].uv_stage, Some(0)); + assert_eq!(frame.passes[1].secondary_uv_stage, Some(1)); + assert_eq!(frame.passes[2].kind, SkyPassKind::DomeGradient); + assert_eq!(frame.passes[3].kind, SkyPassKind::Clouds); + assert_eq!(frame.passes[3].uv_stage, Some(2)); + assert_eq!(frame.passes[3].translation_z, -5000.0); + let rgba = sky.packed[0].rgba(); + assert_eq!( + frame.passes[3].material_color, + Some([rgba[0], rgba[1], rgba[2]]) + ); + } + + #[test] + fn sky_update_rejects_non_finite_input() { + let mut system = SkySystem::new(SkyGeometryConfig::default(), SkyMaterials::default()) + .expect("geometry"); + let mut frame = atmosphere(false); + frame.sky.values[0] = f32::NAN; + assert_eq!( + system.update(&frame, 0.0), + Err(SkyUpdateError::NonFiniteAtmosphere) + ); + } +} diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs index 32d48a1..da0b716 100644 --- a/crates/fparkan-material/src/lib.rs +++ b/crates/fparkan-material/src/lib.rs @@ -67,6 +67,26 @@ pub struct MaterialPhase { pub texture_raw: [u8; 16], } +impl MaterialPhase { + /// Decodes the native floating-point material coefficients. + #[must_use] + pub fn coefficients(&self) -> MaterialCoefficients { + let normalized = |index: usize| f32::from(self.parameters[index]) / 255.0; + MaterialCoefficients { + additive_rgb: [normalized(0), normalized(1), normalized(2)], + opacity: f32::from(self.parameters[3]) * 0.01, + directional_rgb: [normalized(4), normalized(5), normalized(6)], + directional_extra: normalized(7), + specular_rgb: [normalized(8), normalized(9), normalized(10)], + specular_extra: normalized(11), + extra_rgb: [normalized(12), normalized(13), normalized(14)], + extra_alpha: normalized(15), + power: self.parameters[16], + page_index: i8::from_ne_bytes([self.parameters[17]]), + } + } +} + /// Material animation block. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MaterialAnimationBlock { @@ -100,38 +120,88 @@ pub enum MaterialFallback { FirstEntry, } -/// Material timeline mode. +/// Native MAT0 animation mode encoded by `header_raw & 7`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum MaterialTimelineMode { - /// Play once from phase zero. - OneShot, - /// Clamp frame to the last phase. - Clamp, - /// Loop over all phases. +pub enum MaterialAnimationMode { + /// Repeat the timeline from its first key. Loop, - /// Ping-pong over all phases. + /// Repeat the timeline in alternating forward and reverse directions. PingPong, + /// Hold the final key after the timeline duration. + Clamp, + /// Choose a fresh time from the caller-provided random value per query. + RandomPerQuery, + /// Preserve an unknown native mode without making the sampler panic. + Unknown(u8), } -/// Material runtime sampling profile. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct MaterialTimelineProfile { - /// Timeline mode. - pub mode: MaterialTimelineMode, - /// Apply deterministic material-only random offset. - pub random_offset: bool, +impl MaterialAnimationMode { + /// Decodes the low three mode bits from a native block header. + #[must_use] + pub const fn from_header(header_raw: u32) -> Self { + match (header_raw & 7) as u8 { + 0 => Self::Loop, + 1 => Self::PingPong, + 2 => Self::Clamp, + 3 => Self::RandomPerQuery, + other => Self::Unknown(other), + } + } } -/// Sampled material phase. -#[derive(Clone, Debug, Eq, PartialEq)] +impl MaterialAnimationBlock { + /// Returns the native timeline mode from this block's header. + #[must_use] + pub const fn mode(&self) -> MaterialAnimationMode { + MaterialAnimationMode::from_header(self.header_raw) + } + + /// Returns the native interpolation bit mask from `header_raw >> 3`. + #[must_use] + pub const fn interpolation_mask(&self) -> u32 { + self.header_raw >> 3 + } + + /// Returns the final key end time in milliseconds, if a key exists. + #[must_use] + pub fn duration_ms(&self) -> Option { + self.keys.last().map(|key| u32::from(key.k1)) + } +} + +/// Coefficients produced by the native MAT0 phase loader. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MaterialCoefficients { + /// Parameters 0..2, normalized additive RGB. + pub additive_rgb: [f32; 3], + /// Parameter 3, stored as a percentage (`raw * 0.01`). + pub opacity: f32, + /// Parameters 4..6, normalized directional RGB. + pub directional_rgb: [f32; 3], + /// Parameter 7, normalized and held when the animation mask is bit 2. + pub directional_extra: f32, + /// Parameters 8..10, normalized specular RGB. + pub specular_rgb: [f32; 3], + /// Parameter 11, normalized and held when the animation mask is bit 4. + pub specular_extra: f32, + /// Parameters 12..14, normalized extra RGB. + pub extra_rgb: [f32; 3], + /// Parameter 15, normalized and held when the animation mask is bit 8. + pub extra_alpha: f32, + /// Parameter 16, an integer material power value. + pub power: u8, + /// Parameter 17, a signed TEXM page selector. + pub page_index: i8, +} + +/// Sampled native material phase. +#[derive(Clone, Copy, Debug, PartialEq)] pub struct MaterialPhaseSample { /// Selected phase index. pub phase_index: usize, - /// Effective frame after mode and random offset. - pub effective_frame: u32, - /// Sampled parameter bytes. - pub parameters: [u8; 18], - /// Sampled texture bytes. + /// Native floating-point coefficients after optional interpolation. + pub coefficients: MaterialCoefficients, + /// Texture name bytes from the selected phase; never interpolated. pub texture_raw: [u8; 16], } @@ -142,6 +212,8 @@ pub struct ResolvedMaterial { pub name: ResourceName, /// Fallback path. pub fallback: MaterialFallback, + /// Raw NRes `attr1`, retained for renderer blend/category selection. + pub attr1: u32, /// Decoded document. pub document: Mat0Document, } @@ -221,6 +293,29 @@ pub enum MaterialError { MissingMaterial(String), /// A material document has no phases for runtime sampling. EmptyMaterial, + /// An animation block has no positive duration for a multi-key timeline. + InvalidAnimationDuration { + /// Selected animation block. + block_index: usize, + }, + /// An animation key points outside the decoded phase table. + InvalidAnimationPhaseIndex { + /// Selected animation block. + block_index: usize, + /// Key index within the block. + key_index: usize, + /// Raw phase index from the key. + phase_index: u16, + /// Number of decoded phases. + phase_count: usize, + }, + /// Animation key end times are not strictly increasing. + InvalidAnimationKeyTimes { + /// Selected animation block. + block_index: usize, + /// Key index whose end time is invalid. + key_index: usize, + }, } impl From for MaterialError { @@ -271,6 +366,25 @@ impl std::fmt::Display for MaterialError { Self::Resource(message) => write!(f, "{message}"), Self::MissingMaterial(name) => write!(f, "missing material: {name}"), Self::EmptyMaterial => write!(f, "material has no phases"), + Self::InvalidAnimationDuration { block_index } => { + write!(f, "MAT0 animation block {block_index} has zero duration") + } + Self::InvalidAnimationPhaseIndex { + block_index, + key_index, + phase_index, + phase_count, + } => write!( + f, + "MAT0 animation block {block_index} key {key_index} phase {phase_index} outside {phase_count} phases" + ), + Self::InvalidAnimationKeyTimes { + block_index, + key_index, + } => write!( + f, + "MAT0 animation block {block_index} key {key_index} has non-increasing end time" + ), } } } @@ -294,7 +408,8 @@ pub fn decode_wear(bytes: &[u8]) -> Result { return Err(MaterialError::ZeroWearCount); } - let mut entries = Vec::with_capacity(count); + let available_lines = lines.clone().count(); + let mut entries = Vec::with_capacity(count.min(available_lines)); for index in 0..count { let line = lines .next() @@ -425,97 +540,214 @@ pub fn resolve_material( )) } -/// Samples a material phase with deterministic runtime timeline semantics. +/// Samples one native MAT0 animation block. +/// +/// `animation_block_index` selects a block from the already-resolved material +/// table. The packed WEAR handle (table and row) is resolved by the caller; +/// its high half is not an animation-block selector. An out-of-range block +/// uses block zero, matching `GetPhase`. `clock_ms` is the paused-compensated +/// `timeGetTime` value and `wear_row_start_ms` is the start timestamp captured +/// for the owning WEA row; the sampler subtracts the latter with wrapping +/// `u32` arithmetic. For mode [`MaterialAnimationMode::RandomPerQuery`], +/// `random_value` is the caller's fresh native-style random value for this +/// query. The sampler does not own an RNG. /// /// # Errors /// -/// Returns [`MaterialError::EmptyMaterial`] when the MAT0 document has no -/// phases. +/// Returns an error for an empty material, a malformed multi-key duration, +/// non-increasing key end times, or a key that references no phase. pub fn sample_material_phase( document: &Mat0Document, - profile: MaterialTimelineProfile, - frame: u32, - seed: u64, + animation_block_index: usize, + clock_ms: u32, + wear_row_start_ms: u32, + random_value: u32, ) -> Result { - if document.phases.is_empty() { + let Some(first_phase) = document.phases.first() else { return Err(MaterialError::EmptyMaterial); - } - let phase_count = document.phases.len(); - let offset = if profile.random_offset { - material_random_offset(seed, phase_count) - } else { - 0 }; - let effective_frame = frame.wrapping_add(offset); - let phase_index = select_phase_index(profile.mode, effective_frame, phase_count); - let phase = &document.phases[phase_index]; + + let Some((selected_block_index, block)) = document + .animation_blocks + .get(animation_block_index) + .map(|block| (animation_block_index, block)) + .or_else(|| document.animation_blocks.first().map(|block| (0, block))) + else { + return Ok(sample_static_phase(0, first_phase)); + }; + + if block.keys.is_empty() { + return Ok(sample_static_phase(0, first_phase)); + } + + let keys = &block.keys; + let resolve_phase = |key_index: usize, key: &MaterialKey| { + let phase_index = usize::from(key.k0); + document + .phases + .get(phase_index) + .map(|phase| (phase_index, phase)) + .ok_or(MaterialError::InvalidAnimationPhaseIndex { + block_index: selected_block_index, + key_index, + phase_index: key.k0, + phase_count: document.phases.len(), + }) + }; + + if keys.len() == 1 { + let (phase_index, phase) = resolve_phase(0, &keys[0])?; + return Ok(sample_static_phase(phase_index, phase)); + } + + let duration_ms = u32::from(keys[keys.len() - 1].k1); + if duration_ms == 0 { + return Err(MaterialError::InvalidAnimationDuration { + block_index: selected_block_index, + }); + } + validate_key_times(keys, selected_block_index)?; + for (key_index, key) in keys.iter().enumerate() { + resolve_phase(key_index, key)?; + } + + let elapsed_ms = clock_ms.wrapping_sub(wear_row_start_ms); + let mode = block.mode(); + let (local_time_ms, forced_last_key) = match mode { + MaterialAnimationMode::Loop | MaterialAnimationMode::Unknown(_) => { + (elapsed_ms % duration_ms, false) + } + MaterialAnimationMode::PingPong => { + let cycle = elapsed_ms / duration_ms; + let remainder = elapsed_ms % duration_ms; + let local_time = if cycle & 1 == 0 { + remainder + } else { + duration_ms - remainder + }; + (local_time, false) + } + MaterialAnimationMode::Clamp => { + if elapsed_ms >= duration_ms { + // Native clamp selects the last key and leaves the local + // remainder at zero before entering the common interpolation + // path, preserving its unsigned endpoint arithmetic. + (0, true) + } else { + (elapsed_ms, false) + } + } + MaterialAnimationMode::RandomPerQuery => (random_value % duration_ms, false), + }; + + let (key_index, previous_end_ms) = if forced_last_key { + (keys.len() - 1, u32::from(keys[keys.len() - 2].k1)) + } else { + select_key(keys, local_time_ms) + }; + let key = &keys[key_index]; + let next_key_index = (key_index + 1) % keys.len(); + let next_key = &keys[next_key_index]; + let (phase_index, phase) = resolve_phase(key_index, key)?; + let (_, next_phase) = resolve_phase(next_key_index, next_key)?; + let span_ms = u32::from(key.k1).wrapping_sub(previous_end_ms); + if span_ms == 0 { + return Err(MaterialError::InvalidAnimationKeyTimes { + block_index: selected_block_index, + key_index, + }); + } + // The original routine subtracts unsigned dwords before converting both + // values to floating point. Clamp's post-duration endpoint intentionally + // retains this wraparound behavior. + let fraction = local_time_ms.wrapping_sub(previous_end_ms) as f32 / span_ms as f32; + let coefficients = + interpolate_coefficients(phase, next_phase, block.interpolation_mask(), fraction); Ok(MaterialPhaseSample { phase_index, - effective_frame, - parameters: phase.parameters, + coefficients, texture_raw: phase.texture_raw, }) } -/// Interpolates selected parameter bytes according to a bit mask. -/// -/// Unmasked fields are copied from `left`; masked fields are linearly blended -/// and rounded to nearest integer. -#[must_use] -pub fn interpolate_parameter_bytes( - left: [u8; 18], - right: [u8; 18], - interpolation_mask: u32, - t: f32, -) -> [u8; 18] { - let mut out = left; - for (index, value) in out.iter_mut().enumerate() { - if interpolation_mask & (1_u32 << index) == 0 { - continue; +fn sample_static_phase(phase_index: usize, phase: &MaterialPhase) -> MaterialPhaseSample { + MaterialPhaseSample { + phase_index, + coefficients: phase.coefficients(), + texture_raw: phase.texture_raw, + } +} + +fn validate_key_times(keys: &[MaterialKey], block_index: usize) -> Result<(), MaterialError> { + let mut previous_end = 0_u32; + for (key_index, key) in keys.iter().enumerate() { + let end = u32::from(key.k1); + if end <= previous_end { + return Err(MaterialError::InvalidAnimationKeyTimes { + block_index, + key_index, + }); } - let blended = - f32::from(left[index]) + (f32::from(right[index]) - f32::from(left[index])) * t; - *value = rounded_clamped_byte(blended); + previous_end = end; } - out + Ok(()) } -fn rounded_clamped_byte(value: f32) -> u8 { - let rounded = value.round(); - if !rounded.is_finite() || rounded <= 0.0 { - return 0; +fn select_key(keys: &[MaterialKey], local_time_ms: u32) -> (usize, u32) { + let mut previous_end = 0_u32; + for (key_index, key) in keys.iter().enumerate() { + let end = u32::from(key.k1); + if local_time_ms < end { + return (key_index, previous_end); + } + previous_end = end; } - if rounded >= f32::from(u8::MAX) { - return u8::MAX; + // The native backward search falls through to key zero when the local + // value is exactly the duration (the ping-pong turnaround endpoint). + if local_time_ms >= u32::from(keys[keys.len() - 1].k1) { + (0, 0) + } else { + (keys.len() - 1, previous_end) } - (0_u8..=u8::MAX) - .find(|candidate| f32::from(*candidate) >= rounded) - .unwrap_or(u8::MAX) } -/// Builds a deterministic capture for material phase sampling. -/// -/// # Errors -/// -/// Returns [`MaterialError`] when sampling fails. -pub fn material_phase_capture( - document: &Mat0Document, - profile: MaterialTimelineProfile, - frames: &[u32], - seed: u64, -) -> Result, MaterialError> { - let mut out = Vec::new(); - for frame in frames { - let sample = sample_material_phase(document, profile, *frame, seed)?; - out.extend_from_slice( - format!( - "M,{},{},{}\n", - frame, sample.effective_frame, sample.phase_index - ) - .as_bytes(), - ); +fn interpolate_coefficients( + left: &MaterialPhase, + right: &MaterialPhase, + mask: u32, + fraction: f32, +) -> MaterialCoefficients { + let left = left.coefficients(); + let right = right.coefficients(); + let mut output = left; + if mask & 1 != 0 { + output.additive_rgb = lerp3(left.additive_rgb, right.additive_rgb, fraction); } - Ok(out) + if mask & 2 != 0 { + output.directional_rgb = lerp3(left.directional_rgb, right.directional_rgb, fraction); + } + if mask & 4 != 0 { + output.specular_rgb = lerp3(left.specular_rgb, right.specular_rgb, fraction); + } + if mask & 8 != 0 { + output.extra_rgb = lerp3(left.extra_rgb, right.extra_rgb, fraction); + } + if mask & 16 != 0 { + output.opacity = lerp(left.opacity, right.opacity, fraction); + } + output +} + +fn lerp(left: f32, right: f32, fraction: f32) -> f32 { + left + (right - left) * fraction +} + +fn lerp3(left: [f32; 3], right: [f32; 3], fraction: f32) -> [f32; 3] { + [ + lerp(left[0], right[0], fraction), + lerp(left[1], right[1], fraction), + lerp(left[2], right[2], fraction), + ] } impl Mat0Document { @@ -541,46 +773,6 @@ impl Mat0Document { } } -fn select_phase_index(mode: MaterialTimelineMode, frame: u32, phase_count: usize) -> usize { - let count = u32::try_from(phase_count).unwrap_or(u32::MAX).max(1); - let index = match mode { - MaterialTimelineMode::OneShot | MaterialTimelineMode::Clamp => frame.min(count - 1), - MaterialTimelineMode::Loop => frame % count, - MaterialTimelineMode::PingPong => { - if count == 1 { - 0 - } else { - let period = count.saturating_mul(2).saturating_sub(2); - let local = frame % period; - if local < count { - local - } else { - period - local - } - } - } - }; - usize::try_from(index).unwrap_or(phase_count.saturating_sub(1)) -} - -fn material_random_offset(seed: u64, phase_count: usize) -> u32 { - let count = u64::try_from(phase_count).unwrap_or(u64::MAX).max(1); - let mut state = 0xa076_1d64_78bd_642f_u64 ^ seed; - for byte in b"material" { - state ^= u64::from(*byte); - state = splitmix64(state); - } - u32::try_from(splitmix64(state) % count).unwrap_or(0) -} - -fn splitmix64(mut value: u64) -> u64 { - value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); - let mut mixed = value; - mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); - mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); - mixed ^ (mixed >> 31) -} - fn load_material_entry( repository: &dyn ResourceRepository, archive: fparkan_resource::ArchiveId, @@ -599,6 +791,7 @@ fn load_material_entry( Ok(Some(ResolvedMaterial { name: info.key.name, fallback, + attr1: info.attr1, document, })) } @@ -619,6 +812,7 @@ fn load_first_material_entry( Ok(Some(ResolvedMaterial { name: info.key.name, fallback: MaterialFallback::FirstEntry, + attr1: info.attr1, document, })) } @@ -642,7 +836,8 @@ fn parse_lightmaps(lines: &[&str]) -> Result, MaterialError> let count = parse_count(count_line) .map_err(|_| MaterialError::InvalidLightmapCount((*count_line).to_string()))?; cursor += 1; - let mut lightmaps = Vec::with_capacity(count); + let available_lines = lines.len().saturating_sub(cursor); + let mut lightmaps = Vec::with_capacity(count.min(available_lines)); for index in 0..count { let line = lines .get(cursor) @@ -783,6 +978,7 @@ mod tests { use fparkan_nres::ReadProfile; use fparkan_resource::CachedResourceRepository; use fparkan_vfs::MemoryVfs; + use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -813,6 +1009,27 @@ mod tests { assert_eq!(table.lightmaps[0].lightmap.0, b"LM_A"); } + #[test] + fn wear_and_lightmap_counts_do_not_reserve_beyond_available_lines() { + let wear = format!("{}\n", usize::MAX); + assert!(matches!( + decode_wear(wear.as_bytes()), + Err(MaterialError::MissingWearRow { + index: 0, + count: usize::MAX + }) + )); + + let lightmaps = format!("1\n0 MAT\n\nLIGHTMAPS\n{}\n", usize::MAX); + assert!(matches!( + decode_wear(lightmaps.as_bytes()), + Err(MaterialError::MissingLightmapRow { + index: 0, + count: usize::MAX + }) + )); + } + #[test] fn mat0_version_prefix_and_primary_texture() { let mut bytes = vec![0; 4 + 10 + 68]; @@ -899,7 +1116,7 @@ mod tests { #[test] fn resolve_material_uses_exact_match() { let repo = material_repo(&[ - material_entry(b"MAT_A", &mat0_with_texture(b"TEX_A")), + material_entry_with_attr1(b"MAT_A", &mat0_with_texture(b"TEX_A"), 0x1234_5678), material_entry(b"DEFAULT", &mat0_with_texture(b"TEX_DEFAULT")), ]); let table = decode_wear(b"1\n0 MAT_A\n").expect("wear"); @@ -908,6 +1125,7 @@ mod tests { assert_eq!(resolved.name.0, b"MAT_A"); assert_eq!(resolved.fallback, MaterialFallback::Exact); + assert_eq!(resolved.attr1, 0x1234_5678); assert_eq!( resolved.document.primary_texture().expect("texture").0, b"TEX_A" @@ -980,146 +1198,303 @@ mod tests { } #[test] - fn material_modes_zero_to_three_choose_stable_phases() { - let document = - decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); + fn material_animation_header_decodes_native_mode_and_mask() { + let block = MaterialAnimationBlock { + header_raw: (0x1f_u32 << 3) | 3, + keys: Vec::new(), + bytes: Vec::new(), + }; - let cases = [ - (MaterialTimelineMode::OneShot, 9, 2), - (MaterialTimelineMode::Clamp, 9, 2), - (MaterialTimelineMode::Loop, 4, 1), - (MaterialTimelineMode::PingPong, 3, 1), - ]; - for (mode, frame, expected_phase) in cases { - let sample = sample_material_phase( - &document, - MaterialTimelineProfile { - mode, - random_offset: false, - }, - frame, - 0, - ) - .expect("sample"); - assert_eq!(sample.phase_index, expected_phase, "{mode:?}"); - } + assert_eq!(block.mode(), MaterialAnimationMode::RandomPerQuery); + assert_eq!(block.interpolation_mask(), 0x1f); + assert_eq!( + MaterialAnimationMode::from_header(4), + MaterialAnimationMode::Unknown(4) + ); } #[test] - fn material_exact_key_boundary_selects_exact_phase() { - let document = - decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); + fn material_phase_coefficients_decode_native_parameter_slots() { + let mut parameters = [0_u8; 18]; + parameters.copy_from_slice(&[ + 0, 64, 128, 25, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 9, 0xfe, + ]); + let phase = MaterialPhase { + parameters, + texture_raw: [0; 16], + }; - let sample = sample_material_phase( - &document, - MaterialTimelineProfile { - mode: MaterialTimelineMode::Clamp, - random_offset: false, + let coefficients = phase.coefficients(); + assert_eq!( + coefficients.additive_rgb, + [0.0, 64.0 / 255.0, 128.0 / 255.0] + ); + assert!((coefficients.opacity - 0.25).abs() < f32::EPSILON); + assert_eq!( + coefficients.directional_rgb, + [10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0] + ); + assert_eq!(coefficients.directional_extra, 40.0 / 255.0); + assert_eq!( + coefficients.specular_rgb, + [50.0 / 255.0, 60.0 / 255.0, 70.0 / 255.0] + ); + assert_eq!(coefficients.specular_extra, 80.0 / 255.0); + assert_eq!( + coefficients.extra_rgb, + [90.0 / 255.0, 100.0 / 255.0, 110.0 / 255.0] + ); + assert_eq!(coefficients.extra_alpha, 120.0 / 255.0); + assert_eq!(coefficients.power, 9); + assert_eq!(coefficients.page_index, -2); + } + + #[test] + fn material_loop_interpolates_native_groups_and_selects_current_texture() { + let left = phase_with_parameters( + [ + 0, 10, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 7, 1, + ], + b"LEFT", + ); + let right = phase_with_parameters( + [ + 255, 110, 220, 75, 230, 240, 250, 160, 170, 180, 190, 200, 210, 220, 230, 240, 9, 2, + ], + b"RIGHT", + ); + let document = animation_document( + 0x1f_u32 << 3, + vec![left, right], + vec![ + MaterialKey { + k0: 0, + k1: 100, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 200, + k2: 0, + }, + ], + ); + + let sample = sample_material_phase(&document, 0, 150, 0, 0).expect("sample"); + + assert_eq!(sample.phase_index, 1); + assert_eq!(&sample.texture_raw[..5], b"RIGHT"); + assert!((sample.coefficients.additive_rgb[0] - (127.5 / 255.0)).abs() < 1e-6); + assert!((sample.coefficients.opacity - 0.5).abs() < 1e-6); + assert!((sample.coefficients.directional_rgb[1] - (140.0 / 255.0)).abs() < 1e-6); + assert_eq!(sample.coefficients.directional_extra, 160.0 / 255.0); + assert!((sample.coefficients.specular_rgb[2] - (140.0 / 255.0)).abs() < 1e-6); + assert_eq!(sample.coefficients.specular_extra, 200.0 / 255.0); + assert!((sample.coefficients.extra_rgb[0] - (160.0 / 255.0)).abs() < 1e-6); + assert_eq!(sample.coefficients.extra_alpha, 240.0 / 255.0); + assert_eq!(sample.coefficients.power, 9); + assert_eq!(sample.coefficients.page_index, 2); + + let boundary = sample_material_phase(&document, 0, 100, 0, 0).expect("boundary"); + assert_eq!(boundary.phase_index, 1); + assert_eq!(&boundary.texture_raw[..5], b"RIGHT"); + assert_eq!(boundary.coefficients.additive_rgb[0], 1.0); + + let wrapped = sample_material_phase(&document, 0, 200, 0, 0).expect("wrapped"); + assert_eq!(wrapped.phase_index, 0); + assert_eq!(&wrapped.texture_raw[..4], b"LEFT"); + } + + #[test] + fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() { + let phases = vec![ + phase_with_parameters([0; 18], b"A"), + phase_with_parameters([255; 18], b"B"), + ]; + let keys = vec![ + MaterialKey { + k0: 0, + k1: 100, + k2: 0, }, - 1, - 0, - ) - .expect("sample"); + MaterialKey { + k0: 1, + k1: 200, + k2: 0, + }, + ]; + let ping_pong = animation_document((0x1_u32) | (1 << 3), phases.clone(), keys.clone()); + assert_eq!( + sample_material_phase(&ping_pong, 0, 100, 0, 0) + .expect("turn") + .phase_index, + 1 + ); + assert_eq!( + sample_material_phase(&ping_pong, 0, 150, 0, 0) + .expect("reverse") + .phase_index, + 1 + ); + assert_eq!( + sample_material_phase(&ping_pong, 0, 200, 0, 0) + .expect("restart") + .phase_index, + 0 + ); + let turnaround = sample_material_phase(&ping_pong, 0, 200, 0, 0).expect("turnaround"); + assert!((turnaround.coefficients.additive_rgb[0] - 2.0).abs() < 1e-6); + let clamp = animation_document((2_u32) | (1 << 3), phases, keys); + let sample = sample_material_phase(&clamp, 0, 500, 0, 0).expect("clamp"); + assert_eq!(sample.phase_index, 1); + assert!(sample.coefficients.additive_rgb[0] < 0.0); + } + + #[test] + fn material_random_mode_uses_caller_value_and_invalid_block_uses_block_zero() { + let phases = vec![ + phase_with_parameters([0; 18], b"A"), + phase_with_parameters([255; 18], b"B"), + ]; + let keys = vec![ + MaterialKey { + k0: 0, + k1: 100, + k2: 0, + }, + MaterialKey { + k0: 1, + k1: 200, + k2: 0, + }, + ]; + let document = Mat0Document { + version: 0, + animation_block_count: 2, + phases, + prefix: Vec::new(), + header_opaque: [2, 0], + animation_blocks: vec![ + MaterialAnimationBlock { + header_raw: 3, + keys: keys.clone(), + bytes: Vec::new(), + }, + MaterialAnimationBlock { + header_raw: 2, + keys, + bytes: Vec::new(), + }, + ], + }; + + let sample = sample_material_phase(&document, 0, 0, 0, 150).expect("random"); + assert_eq!(sample.phase_index, 1); + let fallback = sample_material_phase(&document, 99, 500, 0, 0).expect("fallback"); + assert_eq!(fallback.phase_index, 0); + } + + #[test] + fn material_static_and_single_key_sampling_uses_phase_zero_or_key_phase() { + let phases = vec![ + phase_with_parameters([1; 18], b"A"), + phase_with_parameters([2; 18], b"B"), + ]; + let static_document = Mat0Document { + version: 0, + animation_block_count: 0, + phases: phases.clone(), + prefix: Vec::new(), + header_opaque: [0, 0], + animation_blocks: Vec::new(), + }; + assert_eq!( + sample_material_phase(&static_document, 0, 123, 50, 0) + .expect("static") + .phase_index, + 0 + ); + + let single_document = animation_document( + 0, + phases, + vec![MaterialKey { + k0: 1, + k1: 0, + k2: 0, + }], + ); + let sample = sample_material_phase(&single_document, 0, 123, 50, 0).expect("single"); assert_eq!(sample.phase_index, 1); assert_eq!(&sample.texture_raw[..1], b"B"); } #[test] - fn material_interpolation_mask_affects_only_selected_fields() { - let mut left = [10_u8; 18]; - let mut right = [20_u8; 18]; - left[1] = 100; - right[1] = 200; - - let out = interpolate_parameter_bytes(left, right, 0b101, 0.5); - - assert_eq!(out[0], 15); - assert_eq!(out[1], 100); - assert_eq!(out[2], 15); - assert_eq!(out[3], 10); - } - - #[test] - fn material_timeline_profile_cases_are_evidence_labeled() { - let document = - decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); - - assert_eq!( - material_phase_capture( - &document, - MaterialTimelineProfile { - mode: MaterialTimelineMode::OneShot, - random_offset: false, + fn material_sampling_rejects_bad_timeline_data() { + let phase = phase_with_parameters([0; 18], b"A"); + let invalid_ref = animation_document( + 0, + vec![phase.clone()], + vec![ + MaterialKey { + k0: 0, + k1: 10, + k2: 0, }, - &[0, 1, 4], - 0, - ) - .expect("one-shot"), - b"M,0,0,0\nM,1,1,1\nM,4,4,2\n" - ); - assert_eq!( - material_phase_capture( - &document, - MaterialTimelineProfile { - mode: MaterialTimelineMode::Loop, - random_offset: false, + MaterialKey { + k0: 4, + k1: 20, + k2: 0, }, - &[0, 1, 4], - 0, - ) - .expect("loop"), - b"M,0,0,0\nM,1,1,1\nM,4,4,1\n" + ], ); - assert_eq!( - material_phase_capture( - &document, - MaterialTimelineProfile { - mode: MaterialTimelineMode::PingPong, - random_offset: false, + assert!(matches!( + sample_material_phase(&invalid_ref, 0, 0, 0, 0), + Err(MaterialError::InvalidAnimationPhaseIndex { .. }) + )); + + let invalid_times = animation_document( + 0, + vec![phase.clone()], + vec![ + MaterialKey { + k0: 0, + k1: 20, + k2: 0, }, - &[0, 1, 3], - 0, - ) - .expect("ping-pong"), - b"M,0,0,0\nM,1,1,1\nM,3,3,1\n" + MaterialKey { + k0: 0, + k1: 10, + k2: 0, + }, + ], ); - } + assert!(matches!( + sample_material_phase(&invalid_times, 0, 0, 0, 0), + Err(MaterialError::InvalidAnimationKeyTimes { .. }) + )); - #[test] - fn material_random_offset_uses_material_stream_only() { - let document = - decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); - let profile = MaterialTimelineProfile { - mode: MaterialTimelineMode::Loop, - random_offset: true, - }; - let before = material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"); - let mut unrelated = 0x5555_u64; - for _ in 0..16 { - unrelated = unrelated.rotate_left(11).wrapping_mul(31); - } - - assert_ne!(unrelated, 0); - assert_eq!( - material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"), - before - ); - } - - #[test] - fn material_same_seed_and_timeline_produces_same_phase_capture() { - let document = - decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); - let profile = MaterialTimelineProfile { - mode: MaterialTimelineMode::Loop, - random_offset: true, - }; - - assert_eq!( - material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("first"), - material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("second") + let zero_duration = animation_document( + 0, + vec![phase], + vec![ + MaterialKey { + k0: 0, + k1: 0, + k2: 0, + }, + MaterialKey { + k0: 0, + k1: 0, + k2: 0, + }, + ], ); + assert!(matches!( + sample_material_phase(&zero_duration, 0, 0, 0, 0), + Err(MaterialError::InvalidAnimationDuration { .. }) + )); } #[test] @@ -1133,6 +1508,8 @@ mod tests { let mut mat0_count = 0usize; let mut archive_wear_count = 0usize; let mut standalone_wear_count = 0usize; + let mut animation_modes = BTreeMap::::new(); + let mut interpolation_masks = BTreeSet::::new(); for path in files_under(&root) { let Ok(bytes) = std::fs::read(&path) else { continue; @@ -1156,9 +1533,16 @@ mod tests { let payload = archive.payload(entry.id()).expect("payload"); match entry.meta().type_id { MAT0_KIND => { - decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| { - panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) - }); + let document = + decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + for block in &document.animation_blocks { + *animation_modes + .entry((block.header_raw & 7) as u8) + .or_default() += 1; + interpolation_masks.insert(block.interpolation_mask()); + } mat0_count += 1; } WEAR_KIND => { @@ -1180,6 +1564,9 @@ mod tests { standalone_wear_count, expected_standalone_wear, "{corpus} standalone WEAR count" ); + eprintln!( + "{corpus} MAT0 animation inventory: modes={animation_modes:?}, masks={interpolation_masks:?}" + ); } } @@ -1223,6 +1610,7 @@ mod tests { struct TestMaterialEntry<'a> { name: &'a [u8], type_id: u32, + attr1: u32, attr2: u32, payload: &'a [u8], } @@ -1231,6 +1619,21 @@ mod tests { TestMaterialEntry { name, type_id: MAT0_KIND, + attr1: 0, + attr2: 0, + payload, + } + } + + fn material_entry_with_attr1<'a>( + name: &'a [u8], + payload: &'a [u8], + attr1: u32, + ) -> TestMaterialEntry<'a> { + TestMaterialEntry { + name, + type_id: MAT0_KIND, + attr1, attr2: 0, payload, } @@ -1254,20 +1657,33 @@ mod tests { bytes } - fn mat0_with_phase_textures(textures: &[&[u8]]) -> Vec { - let mut bytes = vec![0; 4 + textures.len() * 34]; - bytes[0..2].copy_from_slice( - &u16::try_from(textures.len()) - .expect("phase count") - .to_le_bytes(), - ); - for (index, texture) in textures.iter().enumerate() { - let offset = 4 + index * 34; - bytes[offset] = u8::try_from(index).expect("index"); - let len = texture.len().min(16); - bytes[offset + 18..offset + 18 + len].copy_from_slice(&texture[..len]); + fn phase_with_parameters(parameters: [u8; 18], texture: &[u8]) -> MaterialPhase { + let mut texture_raw = [0_u8; 16]; + let len = texture.len().min(texture_raw.len()); + texture_raw[..len].copy_from_slice(&texture[..len]); + MaterialPhase { + parameters, + texture_raw, + } + } + + fn animation_document( + header_raw: u32, + phases: Vec, + keys: Vec, + ) -> Mat0Document { + Mat0Document { + version: 0, + animation_block_count: 1, + phases, + prefix: Vec::new(), + header_opaque: [1, 0], + animation_blocks: vec![MaterialAnimationBlock { + header_raw, + keys, + bytes: Vec::new(), + }], } - bytes } fn build_material_nres(entries: &[TestMaterialEntry<'_>]) -> Vec { @@ -1283,7 +1699,7 @@ mod tests { order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); for (idx, entry) in entries.iter().enumerate() { push_u32(&mut out, entry.type_id); - push_u32(&mut out, 0); + push_u32(&mut out, entry.attr1); push_u32(&mut out, entry.attr2); push_u32( &mut out, diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs index 2e38082..b6459ab 100644 --- a/crates/fparkan-msh/src/lib.rs +++ b/crates/fparkan-msh/src/lib.rs @@ -18,6 +18,9 @@ pub const STREAM_POSITIONS: u32 = 3; pub const STREAM_NORMALS: u32 = 4; /// Texture coordinate stream. pub const STREAM_UV0: u32 = 5; +/// Optional secondary texture coordinate stream used by native lightmap/detail +/// stages. MSH stores it as the type-18 four-byte packed UV stream. +pub const STREAM_UV1: u32 = 18; /// Triangle index stream. pub const STREAM_INDICES: u32 = 6; /// Animation key stream. @@ -81,14 +84,20 @@ pub struct ModelAsset { pub node_count: usize, /// Raw node table. pub nodes_raw: Vec, + /// Model-level bounding sphere center from the Res2 header. + pub bounding_sphere_center: [f32; 3], + /// Model-level bounding sphere radius from the Res2 header. + pub bounding_sphere_radius: f32, /// Slot table. pub slots: Vec, /// Vertex positions. pub positions: Vec<[f32; 3]>, /// Optional normals. pub normals: Option>, - /// Optional texture coordinates. - pub uv0: Option>, + /// Optional primary packed texture coordinates from type 5. + pub uv0: Option>, + /// Optional secondary packed texture coordinates from type 18. + pub uv1: Option>, /// Triangle indices. pub indices: Vec, /// Draw batches. @@ -167,18 +176,24 @@ pub struct Slot { pub struct Batch { /// Batch flags. pub batch_flags: u16, - /// Material index. - pub material_index: u16, - /// Opaque field. - pub opaque4: u16, - /// Opaque field. - pub opaque6: u16, + /// High material selector/slot field at byte offset `+0x02`. + /// + /// The ordinary WEAR material selector is the byte at `+0x04`. This + /// field is retained separately because the native path can override it + /// with a forced material index. + pub material_index_hi: u16, + /// WEAR material selector at byte offset `+0x04`. + pub material_index: u8, + /// Lightmap selector at byte offset `+0x05`. + pub lightmap_index: u8, + /// Local batch index at byte offset `+0x06`. + pub local_batch_index: u16, /// Index count. pub index_count: u16, /// First index offset. pub index_start: u32, - /// Opaque field. - pub opaque14: u16, + /// Vertex count at byte offset `+0x0E`. + pub vertex_count: u16, /// Base vertex. pub base_vertex: u32, } @@ -194,8 +209,10 @@ pub struct VertexStreams { pub positions: Vec<[f32; 3]>, /// Optional normals. pub normals: Option>, - /// Optional texture coordinates. - pub uv0: Option>, + /// Optional primary packed texture coordinates from type 5. + pub uv0: Option>, + /// Optional secondary packed texture coordinates from type 18. + pub uv1: Option>, } /// Preserved non-core stream. @@ -209,11 +226,11 @@ pub struct PreservedStream { pub bytes: std::sync::Arc<[u8]>, } -/// LOD id. +/// Native state id kept under the historical `Lod` API name. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Lod(pub u8); -/// Group id. +/// Native LOD id kept under the historical `Group` API name. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Group(pub u8); @@ -377,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result { Ok(model) } -/// Returns the selected slot for a node/lod/group tuple. +/// Returns the selected slot for a node/native-state/native-LOD tuple. +/// +/// The argument names are retained for API compatibility: `Lod` is the +/// three-value native state and `Group` is the five-value native LOD. #[must_use] pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option { if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 { @@ -417,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option Option { let metadata = node38_metadata(model, node)?; @@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option { .keys .get(usize::from(metadata.fallback_key)) .map(AnimKey24::sampling_pose) + .map(node38_native_local_pose) +} + +/// Converts a sampled MSH source-local quaternion into the native AniMesh +/// quaternion-matrix convention before Node38 hierarchy composition. Native +/// AniMesh's matrix coefficients are the transpose of the portable `Pose` +/// basis. +fn node38_native_local_pose(mut pose: Pose) -> Pose { + pose.rotation[0] = -pose.rotation[0]; + pose.rotation[1] = -pose.rotation[1]; + pose.rotation[2] = -pose.rotation[2]; + pose } /// Evaluates the static fallback pose hierarchy of a standard `Node38` model. @@ -462,8 +495,9 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option { evaluate_hierarchy(&parents, &poses).ok() } -/// Evaluates the portable-reference pose hierarchy of a standard `Node38` -/// model at one explicitly supplied logical frame. +/// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical +/// frame, using portable key sampling and native AniMesh local-matrix +/// convention. /// /// A node without a usable type-19 map, or a requested frame outside the /// declared map length, retains its exact fallback key. Mapped keys are @@ -475,6 +509,59 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option { /// representation or the map cannot be safely resolved. #[must_use] pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option { + node38_sampled_hierarchy_with_selector(model, |_| { + Some((u32::from(frame), AnimationTime(f32::from(frame)))) + }) +} + +/// Evaluates a standard `Node38` hierarchy with an independent floating-point +/// sample time for each node. +/// +/// `Some(time)` uses the nearest-even integer to `time - 0.5` as the type-19 +/// frame-map selector, then samples the selected key and its immediate +/// successor at the original fractional time. This matches the native +/// sampler's selector calculation while keeping interpolation time fractional. +/// The selector uses Rust's default-nearest, ties-to-even rounding. This is +/// the portable counterpart of the native x87 conversion under its default +/// rounding mode; it does not claim parity under every x87 control-word mode. +/// A missing time uses that node's exact fallback key. Supplied times must be +/// finite and non-negative; the caller owns choosing the per-node times. +/// +/// Returns `None` when the model is not a complete standard-node animation, +/// the slice does not match the node count, any supplied time is invalid, or a +/// frame-map entry cannot be safely resolved. +#[must_use] +pub fn node38_sampled_hierarchy_at_times( + model: &ModelAsset, + node_times: &[Option], +) -> Option { + if node_times.len() != model.node_count + || node_times + .iter() + .flatten() + .any(|time| !time.0.is_finite() || time.0 < 0.0) + { + return None; + } + node38_sampled_hierarchy_with_selector(model, |index| { + let time = node_times[index]?; + let selector = (time.0 - 0.5).round_ties_even(); + // A finite, non-negative time can still be beyond the representable + // map range. Saturate to a frame that will resolve to the node's + // fallback key rather than allowing a float-to-index wraparound. + let map_frame = if selector < 0.0 || selector >= u32::MAX as f32 { + u32::MAX + } else { + selector as u32 + }; + Some((map_frame, time)) + }) +} + +fn node38_sampled_hierarchy_with_selector( + model: &ModelAsset, + mut selector_for_node: impl FnMut(usize) -> Option<(u32, AnimationTime)>, +) -> Option { if model.node_stride != 38 || model.node_count == 0 { return None; } @@ -492,21 +579,36 @@ pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option= animation.frame_count { + let sample = selector_for_node(index); + let key_index = if let Some((map_frame, _)) = sample { + if metadata.anim_map_start == u16::MAX + || map_frame == u32::MAX + || map_frame >= animation.frame_count + { fallback_index } else { - let mapped_index = - usize::from(*animation.frame_map.get( - usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?, - )?); + let mapped_index = usize::from( + *animation.frame_map.get( + usize::from(metadata.anim_map_start) + .checked_add(usize::try_from(map_frame).ok()?)?, + )?, + ); if mapped_index < fallback_index { mapped_index } else { fallback_index } - }; - let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?; + } + } else { + fallback_index + }; + let sample_time = sample.map_or(AnimationTime(0.0), |(_, time)| time); + let pose = node38_native_local_pose(sample_node38_key_pair( + &animation.keys, + key_index, + fallback_index, + sample_time, + )?); parents.push(parent); poses.push(pose); } @@ -517,7 +619,7 @@ fn sample_node38_key_pair( keys: &[AnimKey24], key_index: usize, fallback_index: usize, - frame: u16, + sample_time: AnimationTime, ) -> Option { let key = *keys.get(key_index)?; if key_index == fallback_index { @@ -541,7 +643,7 @@ fn sample_node38_key_pair( ], ) .ok()?; - track.sample(AnimationTime(f32::from(frame))).ok() + track.sample(sample_time).ok() } /// Returns draw batches for a validated slot. @@ -667,6 +769,8 @@ fn parse_model_document(document: &NresDocument) -> Result } let node_count = nodes_stream.bytes.len() / node_stride; + let (bounding_sphere_center, bounding_sphere_radius) = + parse_res2_bounding_sphere(&slots_stream.bytes)?; let slots = parse_slots(&slots_stream.bytes)?; let positions = parse_positions(&positions_stream.bytes)?; let indices = parse_u16_array(&indices_stream.bytes, "Res6")?; @@ -678,7 +782,10 @@ fn parse_model_document(document: &NresDocument) -> Result .map(|raw| parse_i8x4_array(&raw.bytes, "Res4")) .transpose()?; let uv0 = read_optional_stream(document, STREAM_UV0)? - .map(|raw| parse_i16x2_array(&raw.bytes, "Res5")) + .map(|raw| parse_u16x2_array(&raw.bytes, "Res5")) + .transpose()?; + let uv1 = read_optional_stream(document, STREAM_UV1)? + .map(|raw| parse_secondary_uv_stream(raw, positions.len())) .transpose()?; let node_names = read_optional_stream(document, STREAM_NAMES)? .map(|raw| parse_res10_names(&raw.bytes, node_count)) @@ -689,10 +796,13 @@ fn parse_model_document(document: &NresDocument) -> Result node_stride, node_count, nodes_raw: nodes_stream.bytes, + bounding_sphere_center, + bounding_sphere_radius, slots, positions, normals, uv0, + uv1, indices, batches, node_names, @@ -822,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result, MshError> { Ok(slots) } +fn parse_res2_bounding_sphere(data: &[u8]) -> Result<([f32; 3], f32), MshError> { + if data.len() < 0x8C { + return Err(MshError::InvalidGeometry(format!( + "invalid Res2 size: {}", + data.len() + ))); + } + let center = [ + read_f32(data, 0x60)?, + read_f32(data, 0x64)?, + read_f32(data, 0x68)?, + ]; + let radius = read_f32(data, 0x6C)?; + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius < 0.0 { + return Err(MshError::InvalidGeometry( + "invalid Res2 bounding sphere".to_string(), + )); + } + Ok((center, radius)) +} + fn parse_positions(data: &[u8]) -> Result, MshError> { if !data.len().is_multiple_of(12) { return Err(invalid_resource_size("Res3", data.len(), 12)); @@ -845,12 +976,17 @@ fn parse_batches(data: &[u8]) -> Result, MshError> { for offset in (0..data.len()).step_by(20) { out.push(Batch { batch_flags: read_u16_required(data, offset)?, - material_index: read_u16_required(data, offset + 2)?, - opaque4: read_u16_required(data, offset + 4)?, - opaque6: read_u16_required(data, offset + 6)?, + material_index_hi: read_u16_required(data, offset + 2)?, + material_index: *data + .get(offset + 4) + .ok_or_else(|| MshError::InvalidGeometry("batch material selector".to_string()))?, + lightmap_index: *data + .get(offset + 5) + .ok_or_else(|| MshError::InvalidGeometry("batch lightmap selector".to_string()))?, + local_batch_index: read_u16_required(data, offset + 6)?, index_count: read_u16_required(data, offset + 8)?, index_start: read_u32(data, offset + 10)?, - opaque14: read_u16_required(data, offset + 14)?, + vertex_count: read_u16_required(data, offset + 14)?, base_vertex: read_u32(data, offset + 16)?, }); } @@ -884,17 +1020,51 @@ fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result, Ms Ok(out) } -fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result, MshError> { +fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result, MshError> { if !data.len().is_multiple_of(4) { return Err(invalid_resource_size(label, data.len(), 4)); } let mut out = Vec::with_capacity(data.len() / 4); for offset in (0..data.len()).step_by(4) { - out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]); + out.push([ + read_u16_required(data, offset)?, + read_u16_required(data, offset + 2)?, + ]); } Ok(out) } +fn parse_secondary_uv_stream( + stream: RawStream, + position_count: usize, +) -> Result, MshError> { + if stream.attributes.attr3 != 4 { + return Err(MshError::InvalidGeometry(format!( + "invalid Res18 stride: expected 4, got {}", + stream.attributes.attr3 + ))); + } + let values = parse_u16x2_array(&stream.bytes, "Res18")?; + let declared_count = usize::try_from(stream.attributes.attr1).map_err(|_| { + MshError::InvalidGeometry("Res18 vertex count does not fit usize".to_string()) + })?; + if declared_count != values.len() { + return Err(MshError::InvalidGeometry(format!( + "Res18 vertex count does not match payload: declared={}, decoded={}", + declared_count, + values.len() + ))); + } + if values.len() != position_count { + return Err(MshError::InvalidGeometry(format!( + "Res18 vertex count does not match Res3: uv1={}, positions={}", + values.len(), + position_count + ))); + } + Ok(values) +} + fn parse_res10_names(data: &[u8], node_count: usize) -> Result>, MshError> { let mut out = Vec::with_capacity(node_count); let mut offset = 0usize; @@ -1038,16 +1208,6 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result { Ok(u16::from_le_bytes(arr)) } -fn read_i16(bytes: &[u8], offset: usize) -> Result { - let raw = bytes - .get(offset..offset.saturating_add(2)) - .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; - let arr: [u8; 2] = raw - .try_into() - .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?; - Ok(i16::from_le_bytes(arr)) -} - fn read_i8(bytes: &[u8], offset: usize) -> Result { let byte = bytes .get(offset) @@ -1309,18 +1469,21 @@ mod tests { let mut nodes = root; nodes.extend(child); let mut keys = Vec::new(); - for (x, y, z, qz, qw) in [ + // Type 8 stores WXYZ, which `AnimKey24::decode` reorders to XYZW. + // The native Node38 local-basis conversion happens before hierarchy + // evaluation, not in the generic animation decoder. + for (x, y, z, qw, qz) in [ (1.0, 0.0, 0.0, 23_170_i16, 23_170_i16), - (2.0, 0.0, 0.0, 0_i16, 32_767_i16), + (2.0, 0.0, 0.0, 32_767_i16, 0_i16), ] { push_f32(&mut keys, x); push_f32(&mut keys, y); push_f32(&mut keys, z); push_f32(&mut keys, 0.0); + push_u16(&mut keys, qw.cast_unsigned()); push_u16(&mut keys, 0); push_u16(&mut keys, 0); push_u16(&mut keys, qz.cast_unsigned()); - push_u16(&mut keys, qw.cast_unsigned()); } let document = decode_nested(&build_nres(&[ stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes), @@ -1336,7 +1499,78 @@ mod tests { validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset"); let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy"); assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001); - assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001); + assert!((hierarchy.poses[1].translation[1] + 2.0).abs() < 0.001); + } + + #[test] + fn node38_native_x_rotation_moves_child_translation_for_fallback_and_sampled_paths() { + let mut root = node38([u16::MAX; 15]); + root[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + root[4..6].copy_from_slice(&0_u16.to_le_bytes()); + root[6..8].copy_from_slice(&1_u16.to_le_bytes()); + let mut child = node38([u16::MAX; 15]); + child[2..4].copy_from_slice(&0_u16.to_le_bytes()); + child[4..6].copy_from_slice(&u16::MAX.to_le_bytes()); + child[6..8].copy_from_slice(&2_u16.to_le_bytes()); + let mut nodes = root; + nodes.extend(child); + let half_sqrt_two = std::f32::consts::FRAC_1_SQRT_2; + let root_rotation = [half_sqrt_two, 0.0, 0.0, half_sqrt_two]; + let identity = [0.0, 0.0, 0.0, 1.0]; + let child_translation = [0.0, -1.01209, -0.001792]; + let model = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, + slots: Vec::new(), + positions: Vec::new(), + normals: None, + uv0: None, + uv1: None, + indices: Vec::new(), + batches: Vec::new(), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![ + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [0.0; 3], + rotation: root_rotation, + }, + }, + AnimKey24 { + time: AnimationTime(1.0), + pose: Pose { + translation: [0.0; 3], + rotation: root_rotation, + }, + }, + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: child_translation, + rotation: identity, + }, + }, + ], + frame_map: vec![0], + frame_count: 1, + }), + }; + + let fallback_root = node38_fallback_pose(&model, NodeId(0)).expect("fallback root pose"); + assert!(fallback_root.rotation[0] < -0.7); + let fallback = node38_fallback_hierarchy(&model).expect("fallback hierarchy"); + let sampled = node38_sampled_hierarchy(&model, 0).expect("sampled hierarchy"); + for hierarchy in [&fallback, &sampled] { + let child_translation = hierarchy.poses[1].translation; + assert!(child_translation[0].abs() < 0.0001); + assert!((child_translation[1] + 0.001792).abs() < 0.0001); + assert!((child_translation[2] - 1.01209).abs() < 0.0001); + } } #[test] @@ -1349,10 +1583,13 @@ mod tests { node_stride: 38, node_count: 1, nodes_raw: node, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, slots: Vec::new(), positions: Vec::new(), normals: None, uv0: None, + uv1: None, indices: Vec::new(), batches: Vec::new(), node_names: None, @@ -1394,6 +1631,105 @@ mod tests { ); } + #[test] + fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() { + let mut root = node38([u16::MAX; 15]); + root[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + root[4..6].copy_from_slice(&0_u16.to_le_bytes()); + root[6..8].copy_from_slice(&4_u16.to_le_bytes()); + let mut child = node38([u16::MAX; 15]); + child[2..4].copy_from_slice(&0_u16.to_le_bytes()); + child[4..6].copy_from_slice(&2_u16.to_le_bytes()); + child[6..8].copy_from_slice(&5_u16.to_le_bytes()); + let mut nodes = root; + nodes.extend(child); + let model = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, + slots: Vec::new(), + positions: Vec::new(), + normals: None, + uv0: None, + uv1: None, + indices: Vec::new(), + batches: Vec::new(), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![ + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [4.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(2.0), + pose: Pose { + translation: [8.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(1.0), + pose: Pose { + translation: [20.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(2.0), + pose: Pose { + translation: [30.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(9.0), + pose: Pose { + translation: [100.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(9.0), + pose: Pose { + translation: [300.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + ], + frame_map: vec![0, 1, 0, 2], + frame_count: 4, + }), + }; + + let hierarchy = node38_sampled_hierarchy_at_times( + &model, + &[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))], + ) + .expect("per-node fractional hierarchy"); + // The root's tie selects map frame zero and interpolates to x=6. The + // child independently selects frame one and samples its own 1.55 time; + // its global pose includes the root translation. + assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON); + assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001); + let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None]) + .expect("per-node fallback hierarchy"); + assert_eq!(fallback.poses[1].translation[0], 306.0); + assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none()); + for invalid in [-1.0, f32::NAN, f32::INFINITY] { + assert!(node38_sampled_hierarchy_at_times( + &model, + &[Some(AnimationTime(invalid)), None] + ) + .is_none()); + } + } + #[test] fn type2_header_and_slot_tail_framing_are_exact() { let too_small = decode_nested(&build_nres(&[ @@ -1420,6 +1756,44 @@ mod tests { assert!(matches!(err, MshError::InvalidGeometry(_))); } + #[test] + fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() { + let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &valid_slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); + assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]); + assert_eq!(model.bounding_sphere_radius, 4.5); + + for (offset, value) in [ + (0x60, f32::NAN), + (0x68, f32::INFINITY), + (0x6C, f32::NAN), + (0x6C, -1.0), + ] { + let mut slots = slots_payload(&[]); + slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + assert!(matches!( + validate_msh(&decode_msh(&document).expect("msh")), + Err(MshError::InvalidGeometry(_)) + )); + } + } + #[test] fn slot_batch_range_out_of_bounds_is_error() { let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]); @@ -1448,6 +1822,7 @@ mod tests { (STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]), (STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]), (STREAM_UV0, b"Res5".as_slice(), vec![0; 3]), + (STREAM_UV1, b"Res18".as_slice(), vec![0; 3]), (STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]), ] { let slots = slots_payload(&[]); @@ -1471,6 +1846,127 @@ mod tests { } } + #[test] + fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() { + let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]); + let mut uv1 = Vec::new(); + for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] { + push_u16(&mut uv1, pair[0]); + push_u16(&mut uv1, pair[1]); + } + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 12, b"Res3", &positions), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1), + ])) + .expect("nested"); + let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); + + assert_eq!(model.positions.len(), 4); + assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]])); + } + + #[test] + fn type18_secondary_uv_rejects_untrusted_count_or_stride() { + let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]); + for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] { + let mut uv1 = Vec::new(); + push_u16(&mut uv1, 2); + push_u16(&mut uv1, 5); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 12, b"Res3", &positions), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1), + ])) + .expect("nested"); + let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid"); + let message = err.to_string(); + assert!(message.contains(expected), "{message}"); + } + } + + #[test] + fn installed_gog_secondary_uv_fixture_is_aligned_and_real() { + let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else { + return; + }; + let root = PathBuf::from(root); + assert!( + root.is_dir(), + "FPARKAN_GAME_ROOT is missing: {}", + root.display() + ); + + let mut models_with_uv1 = 0usize; + let mut fixture_found = false; + for path in files_under(&root) { + if !path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("rlb")) + { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = + fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| has_msh_extension(entry.name_bytes())) + { + let payload = archive.payload(entry.id()).expect("payload"); + let nested = fparkan_nres::decode( + Arc::from(payload.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes())); + let msh = decode_msh(&nested) + .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes())); + let model = validate_msh(&msh) + .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes())); + let Some(uv1) = model.uv1.as_ref() else { + continue; + }; + models_with_uv1 += 1; + assert_eq!( + uv1.len(), + model.positions.len(), + "{path:?} {:?}", + entry.name_bytes() + ); + let descriptor = msh + .streams() + .iter() + .find(|stream| stream.type_id == STREAM_UV1) + .expect("Res18 descriptor"); + assert_eq!(descriptor.attributes.attr1 as usize, uv1.len()); + assert_eq!(descriptor.attributes.attr3, 4); + + if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") { + assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]); + fixture_found = true; + } + } + } + + assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH"); + assert!( + fixture_found, + "FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture" + ); + } + #[test] fn batch20_uses_unaligned_field_offsets() { let positions = positions_payload(&[[0.0, 0.0, 0.0]]); @@ -1488,12 +1984,13 @@ mod tests { let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); assert_eq!(model.batches[0].batch_flags, 0x1100); - assert_eq!(model.batches[0].material_index, 0x2200); - assert_eq!(model.batches[0].opaque4, 0x3300); - assert_eq!(model.batches[0].opaque6, 0x4400); + assert_eq!(model.batches[0].material_index_hi, 0x2200); + assert_eq!(model.batches[0].material_index, 0x00); + assert_eq!(model.batches[0].lightmap_index, 0x33); + assert_eq!(model.batches[0].local_batch_index, 0x4400); assert_eq!(model.batches[0].index_count, 1); assert_eq!(model.batches[0].index_start, 0); - assert_eq!(model.batches[0].opaque14, 0x5500); + assert_eq!(model.batches[0].vertex_count, 0x5500); assert_eq!(model.batches[0].base_vertex, 0); } @@ -1587,44 +2084,49 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] - fn licensed_corpus_msh_assets_validate() { - for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] { - let root = corpus_root(corpus); - let mut count = 0usize; - for path in files_under(&root) { - let Ok(bytes) = std::fs::read(&path) else { - continue; - }; - let Ok(archive) = fparkan_nres::decode( - Arc::from(bytes.into_boxed_slice()), + fn licensed_corpus_part1_msh_assets_validate() { + validate_licensed_corpus_msh_assets("IS", 435); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part2_msh_assets_validate() { + validate_licensed_corpus_msh_assets("IS2", 511); + } + + fn validate_licensed_corpus_msh_assets(corpus: &str, expected: usize) { + let root = corpus_root(corpus); + let mut count = 0usize; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = + fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| has_msh_extension(entry.name_bytes())) + { + let payload = archive.payload(entry.id()).expect("payload"); + let nested = fparkan_nres::decode( + Arc::from(payload.to_vec().into_boxed_slice()), ReadProfile::Compatible, - ) else { - continue; - }; - for entry in archive - .entries() - .iter() - .filter(|entry| has_msh_extension(entry.name_bytes())) - { - let payload = archive.payload(entry.id()).expect("payload"); - let nested = fparkan_nres::decode( - Arc::from(payload.to_vec().into_boxed_slice()), - ReadProfile::Compatible, - ) - .unwrap_or_else(|err| { - panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) - }); - let msh = decode_msh(&nested).unwrap_or_else(|err| { - panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) - }); - validate_msh(&msh).unwrap_or_else(|err| { - panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) - }); - count += 1; - } + ) + .unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())); + let msh = decode_msh(&nested).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + validate_msh(&msh).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + count += 1; } - assert_eq!(count, expected, "{corpus} MSH count"); } + assert_eq!(count, expected, "{corpus} MSH count"); } #[test] @@ -1932,6 +2434,23 @@ mod tests { fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> { TestEntry { type_id, + attr1: 0, + attr3, + name, + payload, + } + } + + fn stream_with_attr1<'a>( + type_id: u32, + attr1: u32, + attr3: u32, + name: &'a [u8], + payload: &'a [u8], + ) -> TestEntry<'a> { + TestEntry { + type_id, + attr1, attr3, name, payload, @@ -1940,6 +2459,7 @@ mod tests { struct TestEntry<'a> { type_id: u32, + attr1: u32, attr3: u32, name: &'a [u8], payload: &'a [u8], @@ -1958,7 +2478,7 @@ mod tests { order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); for (idx, entry) in entries.iter().enumerate() { push_u32(&mut out, entry.type_id); - push_u32(&mut out, 0); + push_u32(&mut out, entry.attr1); push_u32(&mut out, 0); push_u32( &mut out, @@ -2000,7 +2520,19 @@ mod tests { } fn slots_payload(records: &[Vec]) -> Vec { + slots_payload_with_bounds([0.0; 3], 1.0, records) + } + + fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec]) -> Vec { let mut out = vec![0; 0x8c]; + for (offset, value) in [ + (0x60, center[0]), + (0x64, center[1]), + (0x68, center[2]), + (0x6C, radius), + ] { + out[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } for record in records { assert_eq!(record.len(), 68); out.extend_from_slice(record); diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs index 5023df4..dece041 100644 --- a/crates/fparkan-prototype/src/lib.rs +++ b/crates/fparkan-prototype/src/lib.rs @@ -15,6 +15,9 @@ const UNIT_DAT_MIN_SIZE: usize = 0x48; const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1; const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32; +/// `objects.rlb` external geometry entry type (`EXTO`). +pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO"); + /// Prototype key. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct PrototypeKey(pub ResourceName); @@ -61,10 +64,21 @@ pub struct UnitComponentRecord { pub parent_or_link: i32, /// Description raw bytes. pub description_raw: [u8; 32], - /// Opaque tail. + /// Opaque record word at offset `0x68`. pub tail0: u32, - /// Opaque tail. - pub tail1: u32, + /// Number of immediate children in the preorder unit tree. + pub immediate_child_count: u32, +} + +/// One node in a decoded Unit DAT preorder tree. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitComponentTreeNode { + /// Original preorder record index. + pub record_index: usize, + /// Parent record index, or `None` for the root. + pub parent_index: Option, + /// Direct child record indices in source order. + pub children: Vec, } /// Prototype geometry. @@ -85,8 +99,18 @@ pub struct EffectivePrototype { pub geometry: PrototypeGeometry, /// Resolution source. pub source: PrototypeSource, + /// Source registry entry type, when the prototype came from an NRes + /// object registry (for example `EXTO`, `INTO`, or `BTLU`). + pub source_type: Option, /// Resource dependencies discovered while resolving this prototype. pub dependencies: Vec, + /// Explicit control-data resource referenced by this prototype's resolved + /// `objects.rlb` registry inheritance chain, when present. + /// + /// This is deliberately separate from visual dependencies: controller + /// state belongs to each component instance and must not alter shared + /// mesh/material cache identity. + pub control_resource: Option, } /// Prototype resolution source. @@ -401,6 +425,8 @@ pub enum PrototypeError { InvalidSize, /// Invalid unit DAT magic. InvalidUnitDatMagic(u32), + /// Unit DAT child counts do not describe one complete preorder tree. + InvalidUnitHierarchy(String), /// Invalid path. InvalidPath(String), /// VFS error. @@ -435,6 +461,9 @@ impl std::fmt::Display for PrototypeError { Self::InvalidUnitDatMagic(magic) => { write!(f, "invalid unit DAT magic: {magic:#010X}") } + Self::InvalidUnitHierarchy(message) => { + write!(f, "invalid unit DAT preorder hierarchy: {message}") + } Self::InvalidPath(value) => write!(f, "invalid path: {value}"), Self::Vfs(source) => write!(f, "vfs error: {source}"), Self::Resource(source) => write!(f, "resource error: {source}"), @@ -446,7 +475,10 @@ impl std::error::Error for PrototypeError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Decode(source) => Some(source), - Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None, + Self::InvalidSize + | Self::InvalidUnitDatMagic(_) + | Self::InvalidUnitHierarchy(_) + | Self::InvalidPath(_) => None, Self::Vfs(source) => Some(source), Self::Resource(source) => Some(source), } @@ -512,7 +544,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result { let parent_or_link = cursor.read_i32_le()?; description_raw.copy_from_slice(cursor.read_exact(32)?); let tail0 = cursor.read_u32_le()?; - let tail1 = cursor.read_u32_le()?; + let immediate_child_count = cursor.read_u32_le()?; records.push(UnitComponentRecord { archive_raw, resource_raw, @@ -520,7 +552,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result { parent_or_link, description_raw, tail0, - tail1, + immediate_child_count, }); } cursor.require_eof()?; @@ -530,6 +562,86 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result { }) } +/// Builds the immediate-child tree encoded by Unit DAT record counts. +/// +/// Records are stored in preorder. The final word of each 112-byte record is +/// the number of immediate children, so the tree can be reconstructed without +/// treating `parent_or_link` as a parent index. That field is retained as the +/// native mount-socket value for attachment consumers. +/// +/// # Errors +/// +/// Returns [`PrototypeError::InvalidUnitHierarchy`] when child counts consume +/// too few or too many records. +pub fn unit_component_tree( + records: &[UnitComponentRecord], +) -> Result, PrototypeError> { + if records.is_empty() { + return Ok(Vec::new()); + } + + let root_child_count = usize::try_from(records[0].immediate_child_count).map_err(|_| { + PrototypeError::InvalidUnitHierarchy("record 0 child count does not fit usize".to_string()) + })?; + let mut nodes = Vec::with_capacity(records.len()); + nodes.push(UnitComponentTreeNode { + record_index: 0, + parent_index: None, + children: Vec::new(), + }); + // Each open frame holds a preorder node and the direct children still due. + // This keeps hostile deep chains off the call stack. + let mut open_parents = vec![(0_usize, root_child_count)]; + let mut cursor = 1_usize; + while cursor < records.len() { + while open_parents + .last() + .is_some_and(|(_, remaining_children)| *remaining_children == 0) + { + open_parents.pop(); + } + let Some((parent_node_index, remaining_children)) = open_parents.last_mut() else { + return Err(PrototypeError::InvalidUnitHierarchy(format!( + "root tree consumed {cursor} records, but {} were encoded", + records.len() + ))); + }; + let parent_node_index = *parent_node_index; + *remaining_children -= 1; + + let record_index = cursor; + let record = &records[record_index]; + let child_count = usize::try_from(record.immediate_child_count).map_err(|_| { + PrototypeError::InvalidUnitHierarchy(format!( + "record {record_index} child count does not fit usize" + )) + })?; + let node_index = nodes.len(); + nodes.push(UnitComponentTreeNode { + record_index, + parent_index: Some(nodes[parent_node_index].record_index), + children: Vec::new(), + }); + nodes[parent_node_index].children.push(node_index); + cursor += 1; + open_parents.push((node_index, child_count)); + } + + while open_parents + .last() + .is_some_and(|(_, remaining_children)| *remaining_children == 0) + { + open_parents.pop(); + } + if !open_parents.is_empty() { + return Err(PrototypeError::InvalidUnitHierarchy(format!( + "child count reached record {cursor}, but only {} records exist", + records.len() + ))); + } + Ok(nodes) +} + /// Decodes a mission unit DAT binding. /// /// # Errors @@ -1029,7 +1141,7 @@ fn resolve_archive_model( let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else { return Ok(None); }; - Ok(Some(effective(model_key.clone(), mesh, source))) + Ok(Some(effective(model_key.clone(), mesh, source, None, None))) } fn resolve_objects_registry_model( @@ -1037,17 +1149,33 @@ fn resolve_objects_registry_model( registry_archive: &NormalizedPath, object_key: &ResourceName, ) -> Result, PrototypeError> { + let source_type = match repository.open_archive(registry_archive) { + Ok(archive_id) => { + find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))? + .map(|(entry, _)| repository.entry_info(entry)) + .transpose()? + .and_then(|info| info.key.type_id) + } + Err(ResourceError::MissingArchive { .. }) => return Ok(None), + Err(error) => return Err(error.into()), + }; let Some(refs) = collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)? else { return Ok(None); }; + let control_resource = resolve_explicit_control_resource(object_key, &refs)?; let mut missing_mesh_refs = Vec::new(); for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) { - if let Some(prototype) = - resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))? - { + if let Some(prototype) = resolve_object_ref_model( + repository, + object_key, + item, + cstr_bytes(&item.resource_raw), + source_type, + control_resource.clone(), + )? { return Ok(Some(prototype)); } missing_mesh_refs.push(describe_object_ref(item)); @@ -1064,7 +1192,9 @@ fn resolve_objects_registry_model( key: PrototypeKey(object_key.clone()), geometry: PrototypeGeometry::NonGeometric, source: PrototypeSource::ObjectsRegistry, + source_type, dependencies: Vec::new(), + control_resource, })) } @@ -1130,6 +1260,8 @@ fn resolve_object_ref_model( requested: &ResourceName, item: &ObjectRefRecord, model_name: &[u8], + source_type: Option, + control_resource: Option, ) -> Result, PrototypeError> { let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))? @@ -1140,6 +1272,8 @@ fn resolve_object_ref_model( requested.clone(), mesh, PrototypeSource::ObjectsRegistry, + source_type, + control_resource, ))) } @@ -1147,6 +1281,61 @@ fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool { has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh") } +fn is_explicit_control_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"ctl") +} + +fn resolve_explicit_control_resource( + requested: &ResourceName, + refs: &[ObjectRefRecord], +) -> Result, PrototypeError> { + let mut resolved = Vec::new(); + for item in refs.iter().filter(|item| is_explicit_control_ref(item)) { + let archive = + normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; + let name = resource_name(cstr_bytes(&item.resource_raw)); + // CTLD is an optional pose sidecar. Keep the exact registry reference + // here, then let asset preparation treat a missing archive/entry as an + // absent override while still rejecting a present malformed payload. + let key = ResourceKey { + archive, + name, + type_id: None, + }; + if !resolved + .iter() + .any(|existing| same_resource_key(existing, &key)) + { + resolved.push(key); + } + } + match resolved.len() { + 0 => Ok(None), + 1 => Ok(resolved.pop()), + _ => Err(PrototypeError::Resource(ResourceError::Format(format!( + "prototype {} has ambiguous explicit control resources: {}", + String::from_utf8_lossy(&requested.0), + resolved + .iter() + .map(|key| format!( + "{}:{}", + key.archive.as_str(), + String::from_utf8_lossy(&key.name.0) + )) + .collect::>() + .join(", "), + )))), + } +} + +fn same_resource_key(left: &ResourceKey, right: &ResourceKey) -> bool { + left.archive + .as_str() + .eq_ignore_ascii_case(right.archive.as_str()) + && eq_ignore_ascii_case(&left.name.0, &right.name.0) + && left.type_id == right.type_id +} + fn describe_object_ref(item: &ObjectRefRecord) -> String { format!( "{}:{}", @@ -1195,12 +1384,16 @@ fn effective( requested: ResourceName, mesh: ResourceKey, source: PrototypeSource, + source_type: Option, + control_resource: Option, ) -> EffectivePrototype { EffectivePrototype { key: PrototypeKey(requested), geometry: PrototypeGeometry::Mesh(mesh.clone()), source, + source_type, dependencies: vec![mesh], + control_resource, } } @@ -1380,7 +1573,7 @@ mod tests { assert_eq!(record.parent_or_link, -7); assert_eq!(&record.description_raw[..description.len()], description); assert_eq!(record.tail0, 0x1122_3344); - assert_eq!(record.tail1, 0x5566_7788); + assert_eq!(record.immediate_child_count, 0x5566_7788); } #[test] @@ -1406,6 +1599,88 @@ mod tests { assert_eq!(unit.records[0].parent_or_link, 12); } + #[test] + fn unit_component_tree_follows_preorder_child_counts_and_keeps_mount_links() { + let mut bytes = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"root".as_slice()), + (b"objects.rlb".as_slice(), b"child".as_slice()), + (b"objects.rlb".as_slice(), b"grandchild".as_slice()), + ]); + bytes[8 + 68..8 + 72].copy_from_slice(&(-1_i32).to_le_bytes()); + bytes[8 + 108..8 + 112].copy_from_slice(&1_u32.to_le_bytes()); + bytes[8 + 112 + 68..8 + 112 + 72].copy_from_slice(&7_i32.to_le_bytes()); + bytes[8 + 112 + 108..8 + 112 + 112].copy_from_slice(&1_u32.to_le_bytes()); + bytes[8 + 224 + 68..8 + 224 + 72].copy_from_slice(&3_i32.to_le_bytes()); + + let unit = decode_unit_dat(&bytes).expect("unit"); + let tree = unit_component_tree(&unit.records).expect("preorder tree"); + assert_eq!(tree.len(), 3); + assert_eq!(tree[0].record_index, 0); + assert_eq!(tree[0].parent_index, None); + assert_eq!(tree[0].children, vec![1]); + assert_eq!(tree[1].parent_index, Some(0)); + assert_eq!(tree[1].children, vec![2]); + assert_eq!(tree[2].parent_index, Some(1)); + assert_eq!(tree[2].children, Vec::::new()); + assert_eq!(unit.records[1].parent_or_link, 7); + assert_eq!(unit.records[2].parent_or_link, 3); + } + + #[test] + fn unit_component_tree_handles_deep_preorder_chains_iteratively() { + const DEPTH: usize = 32_768; + let records: Vec<_> = (0..DEPTH) + .map(|index| UnitComponentRecord { + archive_raw: [0; 32], + resource_raw: [0; 32], + kind: 0, + parent_or_link: 0, + description_raw: [0; 32], + tail0: 0, + immediate_child_count: u32::from(index + 1 < DEPTH), + }) + .collect(); + + let tree = unit_component_tree(&records).expect("deep preorder chain"); + + assert_eq!(tree.len(), DEPTH); + assert_eq!(tree[0].parent_index, None); + assert_eq!(tree[0].children, vec![1]); + assert_eq!(tree[DEPTH / 2].parent_index, Some(DEPTH / 2 - 1)); + assert_eq!(tree[DEPTH - 2].children, vec![DEPTH - 1]); + assert_eq!(tree[DEPTH - 1].parent_index, Some(DEPTH - 2)); + assert!(tree[DEPTH - 1].children.is_empty()); + } + + #[test] + fn unit_component_tree_rejects_missing_child_records() { + let bytes = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"root".as_slice()), + (b"objects.rlb".as_slice(), b"child".as_slice()), + ]); + let mut unit = decode_unit_dat(&bytes).expect("unit"); + unit.records[0].immediate_child_count = 2; + + assert!(matches!( + unit_component_tree(&unit.records), + Err(PrototypeError::InvalidUnitHierarchy(message)) + if message.contains("child count reached record 2") + )); + } + + #[test] + fn unit_component_tree_rejects_unconsumed_preorder_records() { + let bytes = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"root".as_slice()), + (b"objects.rlb".as_slice(), b"child".as_slice()), + ]); + let unit = decode_unit_dat(&bytes).expect("unit"); + assert!(matches!( + unit_component_tree(&unit.records), + Err(PrototypeError::InvalidUnitHierarchy(_)) + )); + } + #[test] fn resolves_synthetic_objects_registry_model() { let mut vfs = MemoryVfs::default(); @@ -1436,6 +1711,7 @@ mod tests { .expect("prototype"); assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry); + assert!(resolved.control_resource.is_none()); let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { panic!("expected mesh"); }; @@ -1855,10 +2131,10 @@ mod tests { build_nres(&[ ( b"parent_proto".as_slice(), - build_object_refs(&[( - b"static.rlb".as_slice(), - b"parent_proto.msh".as_slice(), - )]) + build_object_refs(&[ + (b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()), + (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()), + ]) .as_slice(), ), ( @@ -1866,6 +2142,7 @@ mod tests { build_object_refs(&[ (b"objects.rlb".as_slice(), b"parent_proto".as_slice()), (b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()), + (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()), ]) .as_slice(), ), @@ -1881,7 +2158,13 @@ mod tests { ); vfs.insert( fortif_path, - Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()), + Arc::from( + build_nres(&[ + (b"child_proto.bas".as_slice(), b"base"), + (b"parent_proto.ctl".as_slice(), b"control"), + ]) + .into_boxed_slice(), + ), ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); @@ -1895,6 +2178,108 @@ mod tests { }; assert_eq!(mesh.archive.as_str(), "static.rlb"); assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh")); + let control = resolved.control_resource.expect("inherited explicit CTL"); + assert_eq!(control.archive.as_str(), "fortif.rlb"); + assert!(control.name.0.eq_ignore_ascii_case(b"parent_proto.ctl")); + } + + #[test] + fn objects_registry_retains_missing_explicit_control_metadata() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"optional_control".as_slice(), + build_object_refs(&[ + (b"static.rlb".as_slice(), b"optional_control.msh".as_slice()), + ( + b"missing.rlb".as_slice(), + b"optional_control.ctl".as_slice(), + ), + ]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"optional_control.msh".as_slice(), mesh.as_slice())]) + .into_boxed_slice(), + ), + ); + + let vfs = Arc::new(vfs); + let repository = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype_single( + &repository, + vfs.as_ref(), + &resource_name(b"optional_control"), + ) + .expect("resolve despite absent optional CTL") + .expect("prototype"); + + let control = resolved + .control_resource + .expect("preserve explicit missing CTL reference"); + assert_eq!(control.archive.as_str(), "missing.rlb"); + assert_eq!(control.name, resource_name(b"optional_control.ctl")); + assert_eq!(control.type_id, None); + } + + #[test] + fn objects_registry_rejects_distinct_explicit_controls() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let controls_path = resource_archive_path(b"controls.rlb").expect("controls path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"prototype".as_slice(), + build_object_refs(&[ + (b"static.rlb".as_slice(), b"prototype.msh".as_slice()), + (b"controls.rlb".as_slice(), b"first.ctl".as_slice()), + (b"controls.rlb".as_slice(), b"second.ctl".as_slice()), + ]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"prototype.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + vfs.insert( + controls_path, + Arc::from( + build_nres(&[ + (b"first.ctl".as_slice(), b"first control"), + (b"second.ctl".as_slice(), b"second control"), + ]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let error = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"prototype")) + .expect_err("distinct explicit controls are ambiguous"); + + assert!(matches!( + error, + PrototypeError::Resource(ResourceError::Format(message)) + if message.contains("ambiguous explicit control resources") + )); } #[test] diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index 7e3c1f8..08a285d 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -319,7 +319,7 @@ impl LegacyIron3dEulerTransform { /// Fixed-function blend behaviour represented without a graphics API type. /// -/// This is a compatibility contract, not yet a decoded MAT0 mapping. +/// This is the compatibility contract for the native MAT0 pipeline category. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum LegacyBlendMode { /// Do not blend the fragment with the existing colour. @@ -327,6 +327,14 @@ pub enum LegacyBlendMode { Opaque, /// Blend using source alpha. SourceAlpha, + /// Add the source-alpha-scaled fragment to the existing colour. + Additive, + /// Replace the source factor with zero and use source colour as the + /// destination factor. + ZeroSourceColor, + /// Multiply the source by destination colour and destination by source + /// colour, matching the native two-colour blend mode. + DestColorSourceColor, } /// Depth-buffer behaviour represented without a graphics API type. @@ -369,10 +377,36 @@ pub struct LegacyPipelineState { pub alpha_test: bool, } +impl LegacyPipelineState { + /// Applies the native MAT0 pipeline category while retaining the caller's + /// depth and cull state. + /// + /// Categories 0..4 are the complete table recovered from the native + /// material path. The native table enables `GREATER_EQUAL` alpha testing + /// for categories 1..4; the alpha reference remains per draw range. + #[must_use] + pub const fn with_material_category(self, category: u8) -> Option { + let (blend, alpha_test) = match category { + 0 => (LegacyBlendMode::Opaque, false), + 1 => (LegacyBlendMode::SourceAlpha, true), + 2 => (LegacyBlendMode::Additive, true), + 3 => (LegacyBlendMode::ZeroSourceColor, true), + 4 => (LegacyBlendMode::DestColorSourceColor, true), + _ => return None, + }; + Some(Self { + blend, + alpha_test, + ..self + }) + } +} + /// 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. +/// remain stable across processes and Rust toolchain updates. Three bits are +/// reserved for the five native material blend modes. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct PipelineKey(u8); @@ -389,6 +423,9 @@ impl From for PipelineKey { let blend = match state.blend { LegacyBlendMode::Opaque => 0, LegacyBlendMode::SourceAlpha => 1, + LegacyBlendMode::Additive => 2, + LegacyBlendMode::ZeroSourceColor => 3, + LegacyBlendMode::DestColorSourceColor => 4, }; let depth = match state.depth { LegacyDepthMode::Disabled => 0, @@ -400,7 +437,7 @@ impl From for PipelineKey { LegacyCullMode::BackFace => 1, LegacyCullMode::FrontFace => 2, }; - Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5)) + Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7)) } } @@ -549,4 +586,49 @@ mod tests { None ); } + + #[test] + fn native_material_categories_preserve_depth_and_cull_state() { + let base = LegacyPipelineState { + depth: LegacyDepthMode::TestWrite, + cull: LegacyCullMode::BackFace, + ..LegacyPipelineState::default() + }; + let expected = [ + (LegacyBlendMode::Opaque, false), + (LegacyBlendMode::SourceAlpha, true), + (LegacyBlendMode::Additive, true), + (LegacyBlendMode::ZeroSourceColor, true), + (LegacyBlendMode::DestColorSourceColor, true), + ]; + for (category, (blend, alpha_test)) in expected.into_iter().enumerate() { + let state = base + .with_material_category(category as u8) + .expect("native category is represented"); + assert_eq!(state.blend, blend); + assert_eq!(state.alpha_test, alpha_test); + assert_eq!(state.depth, base.depth); + assert_eq!(state.cull, base.cull); + } + assert_eq!(base.with_material_category(5), None); + } + + #[test] + fn native_material_categories_have_distinct_pipeline_keys() { + let base = LegacyPipelineState { + depth: LegacyDepthMode::TestWrite, + ..LegacyPipelineState::default() + }; + let keys: Vec<_> = (0..=4) + .map(|category| { + PipelineKey::from( + base.with_material_category(category) + .expect("native category is represented"), + ) + }) + .collect(); + for (index, key) in keys.iter().enumerate() { + assert!(keys[index + 1..].iter().all(|other| other != key)); + } + } } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 2fead30..e0088a3 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -1761,7 +1761,7 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] - fn licensed_corpora_load_all_mission_foundations() { + fn licensed_corpus_part1_loads_all_mission_foundations() { let part1 = load_all(&licensed_root("IS")); assert_eq!(part1.missions, 29); assert_eq!(part1.paths, 34); @@ -1781,7 +1781,11 @@ mod tests { assert_eq!(part1.material_slots, part1.material_resolved); assert_eq!(part1.texture_requests, part1.texture_resolved); assert_eq!(part1.lightmap_requests, part1.lightmap_resolved); + } + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part2_loads_all_mission_foundations() { let part2 = load_all(&licensed_root("IS2")); assert_eq!(part2.missions, 31); assert_eq!(part2.paths, 61); diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 3c05cfe..639fc57 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -43,16 +43,18 @@ pub struct CompactSurfaceMask(pub u16); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MaterialClassMask(pub u8); -/// The two positional material-table selectors packed into a terrain face tag. +/// The base and overlay selectors packed into a terrain face tag. /// -/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed -/// no-selection sentinel. The low byte selects from `Land2.wea`. +/// Both selectors address the map-local `Land1.wea` table. The low byte is +/// always the base row; the high byte is an optional overlay row and uses +/// `0xff` as its no-overlay sentinel. `Land2.wea` is a separate detail bank +/// selected by the corresponding detail path. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TerrainMaterialLayers { - /// Optional high-byte selector for `Land1.wea`. - pub land1_selector: Option, - /// Low-byte selector for `Land2.wea`. - pub land2_selector: u8, + /// Low-byte base selector for `Land1.wea`. + pub base_selector: u8, + /// Optional high-byte overlay selector for `Land1.wea`. + pub overlay_selector: Option, } /// One `World3D` material-manager lookup key. @@ -79,31 +81,56 @@ impl TerrainMaterialLayers { /// Constructs the decoded pair from its on-disk packed tag. #[must_use] pub const fn from_packed_tag(material_tag: u16) -> Self { - let [land2_selector, land1] = material_tag.to_le_bytes(); + let [base_selector, overlay] = material_tag.to_le_bytes(); Self { - land1_selector: if land1 == u8::MAX { None } else { Some(land1) }, - land2_selector, + base_selector, + overlay_selector: if overlay == u8::MAX { + None + } else { + Some(overlay) + }, } } - /// Returns the table-zero `Land1.wea` selection when it exists. + /// Returns the base selection from `Land1.wea`. #[must_use] - pub fn land1_selection(self) -> Option { - self.land1_selector + pub fn base_selection(self) -> TerrainMaterialSelection { + TerrainMaterialSelection { + table_index: 0, + material_index: u16::from(self.base_selector), + } + } + + /// Returns the optional overlay selection from `Land1.wea`. + #[must_use] + pub fn overlay_selection(self) -> Option { + self.overlay_selector .map(|material_index| TerrainMaterialSelection { table_index: 0, material_index: u16::from(material_index), }) } - /// Returns the table-one `Land2.wea` selection. + /// Returns the detail selection corresponding to the base row in + /// `Land2.wea`. #[must_use] - pub fn land2_selection(self) -> TerrainMaterialSelection { + pub fn detail_base_selection(self) -> TerrainMaterialSelection { TerrainMaterialSelection { table_index: 1, - material_index: u16::from(self.land2_selector), + material_index: u16::from(self.base_selector), } } + + /// Returns the optional detail selection corresponding to the overlay row + /// in `Land2.wea`. + #[must_use] + pub fn detail_overlay_selection(self) -> Option { + self.overlay_selector + .map(|material_index| TerrainMaterialSelection { + table_index: 1, + material_index: u16::from(material_index), + }) + } } /// Terrain face with 28-byte source layout. @@ -231,7 +258,7 @@ pub struct LandMeshDocument { /// Packed normals from type 4. pub normals: Vec<[i8; 4]>, /// Packed UV from type 5. - pub uv0: Vec<[i16; 2]>, + pub uv0: Vec<[u16; 2]>, /// Type 11 accelerator words. pub accelerator: Vec<[u8; 4]>, /// Type 14 auxiliary words. @@ -631,7 +658,7 @@ pub fn decode_land_msh(nres: &NresDocument) -> Result Result Result, TerrainFormatError> { +) -> Result, TerrainFormatError> { if !payload.len().is_multiple_of(4) { return Err(TerrainFormatError::InvalidSize { type_id, @@ -1088,7 +1120,7 @@ fn parse_i16x2_stream( validate_stream(nres, type_id, 4, count)?; let mut out = Vec::with_capacity(count); for chunk in payload.chunks_exact(4) { - out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]); + out.push([read_u16(chunk, 0)?, read_u16(chunk, 2)?]); } Ok(out) } @@ -1311,6 +1343,7 @@ fn parse_areal_grid( if cell_count == 0 { return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y }); } + checked_count_bytes(u64::from(cell_count), 2, cursor.remaining() as u64)?; let cell_count_usize = usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?; let mut cells = Vec::with_capacity(cell_count_usize); @@ -1364,13 +1397,6 @@ fn read_u16(bytes: &[u8], offset: usize) -> Result { Ok(u16::from_le_bytes([raw[0], raw[1]])) } -fn read_i16(bytes: &[u8], offset: usize) -> Result { - let raw = bytes - .get(offset..offset + 2) - .ok_or(TerrainFormatError::IntegerOverflow)?; - Ok(i16::from_le_bytes([raw[0], raw[1]])) -} - fn read_u32(bytes: &[u8], offset: usize) -> Result { let raw = bytes .get(offset..offset + 4) @@ -1574,7 +1600,7 @@ mod tests { } #[test] - fn terrain_material_tag_decodes_two_sidecar_selectors() { + fn terrain_material_tag_decodes_base_overlay_and_detail_selectors() { let mut raw_face = face([0, 1, 2], [None, None, None]); raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes()); let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); @@ -1583,24 +1609,40 @@ mod tests { assert_eq!( document.faces[0].material_layers(), TerrainMaterialLayers { - land1_selector: Some(1), - land2_selector: 2, + base_selector: 2, + overlay_selector: Some(1), } ); assert_eq!( - document.faces[0].material_layers().land1_selection(), - Some(TerrainMaterialSelection { + document.faces[0].material_layers().base_selection(), + TerrainMaterialSelection { table_index: 0, - material_index: 1, - }) + material_index: 2, + } ); assert_eq!( document.faces[0] .material_layers() - .land2_selection() + .overlay_selection() + .expect("overlay") + .phase_key(), + 0x0000_0001 + ); + assert_eq!( + document.faces[0] + .material_layers() + .detail_base_selection() .phase_key(), 0x0001_0002 ); + assert_eq!( + document.faces[0] + .material_layers() + .detail_overlay_selection() + .expect("detail overlay") + .phase_key(), + 0x0001_0001 + ); raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes()); let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); @@ -1608,18 +1650,28 @@ mod tests { assert_eq!( document.faces[0].material_layers(), TerrainMaterialLayers { - land1_selector: None, - land2_selector: 3, + base_selector: 3, + overlay_selector: None, } ); - assert_eq!(document.faces[0].material_layers().land1_selection(), None); assert_eq!( - document.faces[0].material_layers().land2_selection(), + document.faces[0].material_layers().base_selection(), TerrainMaterialSelection { - table_index: 1, + table_index: 0, material_index: 3, } ); + assert_eq!( + document.faces[0].material_layers().overlay_selection(), + None + ); + assert_eq!( + document.faces[0] + .material_layers() + .detail_base_selection() + .phase_key(), + 0x0001_0003 + ); } #[test] @@ -1637,6 +1689,21 @@ mod tests { assert_eq!(document.grid.compact_cells, [0x0040_0000]); } + #[test] + fn land_map_bounds_area_and_grid_reservations_by_payload() { + let empty = build_nres(&[entry(TYPE_AREAL_MAP, u32::MAX, 0, &[])]); + let nres = decode_nres(&empty).expect("nres"); + assert!(decode_land_map(&nres).is_err()); + + let mut payload = Vec::new(); + push_areal_prefix(&mut payload, 0, 0); + push_u32(&mut payload, 65_535); + push_u32(&mut payload, 65_535); + let bytes = build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]); + let nres = decode_nres(&bytes).expect("nres"); + assert!(decode_land_map(&nres).is_err()); + } + #[test] fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() { let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres"); diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index 7dced19..cd2a874 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -1,14 +1,16 @@ #![forbid(unsafe_code)] //! Validated terrain runtime queries using XY ground coordinates and Z height. -use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument}; +use fparkan_terrain_format::{LandMapDocument, LandMeshDocument}; use std::collections::VecDeque; /// Terrain material-selector contract preserved from the decoded map mesh. /// /// Applications access this semantic terrain API through this terrain crate /// rather than taking a direct dependency on the binary-format parser. -pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection}; +pub use fparkan_terrain_format::{ + FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection, +}; /// Terrain world. #[derive(Clone, Debug, Default)] @@ -141,6 +143,21 @@ pub trait SurfaceQuery { direction: [f32; 3], mask: FullSurfaceMask, ) -> Result, TerrainError>; + + /// Raycast while requiring every bit in `include` and rejecting every + /// face carrying a bit in `exclude`. + /// + /// The native landscape query has both predicates. The legacy + /// [`Self::raycast`] shorthand keeps its historical include-only form; + /// callers that model a native receiver or visibility test should use + /// this method so exclusion flags cannot be mistaken for inclusion bits. + fn raycast_excluding( + &self, + origin: [f32; 3], + direction: [f32; 3], + include: FullSurfaceMask, + exclude: FullSurfaceMask, + ) -> Result, TerrainError>; } /// Navigation query. @@ -404,6 +421,28 @@ impl SurfaceQuery for TerrainWorld { origin: [f32; 3], direction: [f32; 3], mask: FullSurfaceMask, + ) -> Result, TerrainError> { + self.raycast_filtered(origin, direction, mask, FullSurfaceMask(0)) + } + + fn raycast_excluding( + &self, + origin: [f32; 3], + direction: [f32; 3], + include: FullSurfaceMask, + exclude: FullSurfaceMask, + ) -> Result, TerrainError> { + self.raycast_filtered(origin, direction, include, exclude) + } +} + +impl TerrainWorld { + fn raycast_filtered( + &self, + origin: [f32; 3], + direction: [f32; 3], + include: FullSurfaceMask, + exclude: FullSurfaceMask, ) -> Result, TerrainError> { if self.surfaces.is_empty() { return Err(TerrainError::Unsupported); @@ -413,7 +452,10 @@ impl SurfaceQuery for TerrainWorld { let mut best: Option = None; for index in candidates { let triangle = &self.surfaces[index]; - if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 { + if include.0 != 0 && triangle.mask.0 & include.0 != include.0 { + continue; + } + if triangle.mask.0 & exclude.0 != 0 { continue; } let Some(distance) = triangle.raycast(origin, direction) else { @@ -1032,6 +1074,31 @@ mod tests { .expect("raycast"), None ); + + // Native include masks require every requested bit, while exclusion + // masks reject any matching face bit. + assert_eq!( + world + .raycast_excluding( + [0.25, 0.25, 2.0], + [0.0, 0.0, -1.0], + FullSurfaceMask(0x0000_0003), + FullSurfaceMask(0), + ) + .expect("raycast include all bits"), + None + ); + assert_eq!( + world + .raycast_excluding( + [0.25, 0.25, 2.0], + [0.0, 0.0, -1.0], + FullSurfaceMask(0), + FullSurfaceMask(0x0000_0001), + ) + .expect("raycast exclusion"), + None + ); } #[test] diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 1888f35..64dc4fc 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -10,7 +10,7 @@ messages, teleports, задачи, research и campaign transitions. Точки и `briefing.cfg`. `.scr` — binary package с version checks, symbol/event sections и offsets; -полная opcode grammar не доказана. Его внешний framing теперь читает +полная opcode grammar не доказана. Его внешний framing читает `fparkan-script`: первый little-endian `u32` является числом required opcode handlers, второй — числом event records. Каждый event хранит `name_len`, `name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных @@ -108,24 +108,21 @@ unsupported result, а не «примерный» game command. записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту функцию как пустой `return`. Следовательно, найденные `_Start` и `_Continue` только кэшируются в scheduler state; их фактический consumer -находится в отдельном позднем update path. Воспроизводимый read-only extractor: -`tools/ghidra/ExportAiVmHandler2Dispatch.java`. +находится в отдельном позднем update path. -Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr` -имеются 6 087 instruction records, из них 3 992 sentinel; самый частый -non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266` -читает первые два reference words активной instruction, разрешает каждый -через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с -тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его +Во всех 58 GOG `.scr` имеются 6 087 instruction records, включая 3 992 +sentinel. Самый частый non-sentinel selector — `Handler(30)`, 246 records. +Его VA `0x1000c266` читает первые два reference words активной instruction, +разрешает каждый через varset (`0x10002d30` и `0x10013570`) и вызывает внешний +callback с тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word `1`; предметная семантика этих modes ещё не доказана. В частности, это пока не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный text-to-varset resolver расположен за wrapper `0x10011ea0` в -`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`, -`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`. +`0x100174a0`. -Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый +`0x100174a0` добавляет каждый recognized source declaration в encounter order как 48-byte record; GOG shared `varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries образуют точно это index space. `0x10013570` возвращает `DWORD` record kind @@ -134,10 +131,9 @@ capture. Полный GOG scan всех 246 Handler(30) instructions показ operand references: все 492 in-range и указывают на `DWORD`. Поэтому `VarSet::resolve_handler30` уже materializes точный opaque callback command `(mode=0, first, second)` для данного corpus path, но явно отклоняет float, -out-of-range и incomplete instructions вместо silent coercion. Extractors: -`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`. +out-of-range и incomplete instructions вместо silent coercion. -Следующий static pass закрывает equality/update policy. Identity ровно равна +Identity ровно равна `(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и `+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`, slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При @@ -147,7 +143,7 @@ slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+ изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode, не назначает игровых имён и не делает event lookup за original VM. -На границе mission runtime выбранный TMA clan `first_resource` теперь +На границе mission runtime выбранный TMA clan `first_resource` материализуется как отдельный `MissionScriptBundle`: loader нормализует `.scr`, декодирует его тем же bounded reader-ом и публикует immutable package вместе с clan provenance. Это именно wiring входных данных, не VM @@ -206,9 +202,9 @@ contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`; the Rust loader reports `script_init_states=2` and `script_varset_states=2`. `GetSuperAI` returns element `n` of the 64-pointer global table at preferred -`ai.dll + 0x55398` for `n <= 63`. The read-only -`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values -`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields +`ai.dll + 0x55398` for `n <= 63`. A read-only capture of the running GOG +AutoDemo process observed `(500, 752, 0)` for entry 0 and `(728, 449, 1)` for +entry 1 at fields `(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding profile. @@ -216,12 +212,7 @@ The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already converted first two words and the third raw word, produces three typed writes, and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only binds it to the proven creation/anchor path above; it does not guess the -remaining script event semantics. The associated Ghidra scripts are -`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`, -`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`. -The creation and conversion boundaries are reproducible with -`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and -`ExportAiFtol.java`. +remaining script event semantics. ### Runtime Handler(30) operand binding @@ -256,12 +247,7 @@ proves the lookup and one-shot/repeat split, not the UI/message subject or the semantics of either resource ID; Rust therefore retains command `1` as `Unhandled`. -Reproduce the callback and the command-one consumers with -`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`, -`ExportIron3dAiCallbackCommand1.java`, and -`ExportIron3dAiCallbackCommand1Dispatch.java`. - -Runtime now applies only this recovered branch as +Runtime applies only this branch as `apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission to `Failed`, `(0, 0, 1)` transitions it to `Completed`, and a repeated target state is a no-op just as the Iron3D guards require. The effect is deliberately @@ -284,9 +270,7 @@ opaque callback slots; state `3` does the same except word `3` is preserved. Both then write `+0x18`. Every other state simply writes the state word. `VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned live record index, resolved state, and explicit reset kind. It does not invent -the table owner, the pre-reset helper, or callback semantics. Reproduce the -evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and -`ExportAiVmHandler8Transitions.java`. +the table owner, the pre-reset helper, or callback semantics. ### Handler(15): typed target-call boundary @@ -314,8 +298,7 @@ with that record and the third word. Its zero/non-zero result becomes return value remain unproven. Accordingly `VarSet::resolve_handler15` only materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`; it never executes the opaque target call. Missing, out-of-range, wrong-type, -and unobserved-mode inputs are explicit errors. Reproduce the static evidence -with `tools/ghidra/ExportAiVmHandler15.java`. +and unobserved-mode inputs are explicit errors. ## Готовность diff --git a/docs/index.md b/docs/index.md index 25069a0..b6e62f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,6 +18,13 @@ FParkan воспроизводит его работу на Rust и Vulkan. На 7. [Работа над движком](tomes/07-implementation.md): код, тесты и проверка результата. 8. [Устройство оригинальной программы](tomes/08-evidence.md): DLL, адреса и конфигурация. +В справочнике можно быстро сверить [NRes](reference/nres.md), +[RsLi](reference/rsli.md), [TMA](reference/tma.md), [MSH](reference/msh.md), +[материалы](reference/materials.md), [Texm](reference/texm.md), +[кадр рендера](reference/render-frame.md), [атмосферу и небо](reference/atmosphere.md) +и [эффекты окружения](reference/environment-effects.md). Таблица DLL находится +в [описании оригинальных модулей](reference/original-binaries.md). + В [глоссарии](appendices/glossary.md) собраны термины, а в [открытых вопросах](appendices/knowledge-boundaries.md) — ещё не восстановленное поведение. Описание алгоритма оригинала и возможности текущего приложения diff --git a/docs/reference/atmosphere.md b/docs/reference/atmosphere.md new file mode 100644 index 0000000..65ed176 --- /dev/null +++ b/docs/reference/atmosphere.md @@ -0,0 +1,236 @@ +# Атмосфера и небо + +Атмосфера Parkan задаётся двумя связанными ресурсами: `sky.ske` хранит +расписание, цвета и параметры, а `sky.wea` сопоставляет позиционные строки с +материалами. Ресурсы сначала разбираются в `TypedAtmosphere` и `SkyMaterials`, +затем на каждом кадре из расписания получается `AtmosphereFrame`. Модуль +`fparkan-fx::sky` превращает этот кадр в данные для рендерера: постоянную +геометрию купола, динамические цвета вершин, небесные слои, положение +светил, туман и нижнюю границу освещения. + +## Игровые сутки и смена дня и ночи + +Вызов `TypedAtmosphere::sample(real_seconds)` принимает секунды от начала +повторяющегося расписания. Дорожки идут в порядке файла и занимают свои +реальные длительности; после последней дорожки время возвращается к началу. +Внутри выбранной дорожки игровой день всегда проходит от 00:00 до 24:00: + +```text +track_seconds = real_seconds_in_track + day_seconds = track_seconds / track_duration * 86400 +``` + +Время ключа вычисляется по часам и минутам даты. Нативный конструктор +игнорирует сохранённое поле секунд, поэтому для планировщика используется: + +```text +duration = (end_hour * 60 + end_minute) * 60 +key_time = floor(duration * (key_hour * 60 + key_minute) * 60 / 86400) +``` + +Сэмплер ищет предыдущий и следующий ключ во всём расписании. Переход через +конец дорожки или конец цикла интерполируется так же, как переход внутри +дорожки. `SkeDate::seconds_of_day()` сохраняет все три компонента даты для +инструментов; оно не меняет формулу реального времени расписания. + +День и ночь в самом формате не являются отдельным флагом. Их вид получается +из интерполированных цветовых параметров купола и состояния объектов солнца и +луны. Ключи с kind `0` и `1` запускают и останавливают небесное светило. Для +активного интервала модуль восстанавливает рождение объекта: ищет ближайший +предыдущий ключ `SunStart`, следующий соответствующий `SunStop`, вычисляет +реальное время начала и длительность, включая переход через цикл, и сохраняет +эти значения в `SunBirth`. + +Имя в первой фиксированной строке определяет объект. Для строки `sun` +нативный код использует углы `[90, 30]` градусов и строку материала 3; для +`moon` — `[0, 50]` и строку 4. Начальный вектор орбиты строится по формуле +`[cos(theta), 0, -sin(theta)]`, где + +```text +theta = (clamp((now - birth_start) / lifetime, 0, 1) * 1.2 - 0.1) * pi +``` + +Затем к нему применяется матрица рождения. У солнца и луны независимые +интервалы и независимые направления: при отсутствии активной луны нельзя +подменять её направление противоположностью солнца. `SunFrame::color` — это +единственный primary directional RGB. Второй источник получает направление +`-SunFrame::direction` и RGB из `SunSample::packed[1]`; оба источника для +активного объекта находятся в `SkyFrame::directional_lights` (сначала солнце, +затем луна). Экранная геометрия использует `-SunFrame::direction`, а +`world_center` размещает её по нативной формуле относительно camera +translation и половины far plane. Материал, строка и состояние объекта +берутся из его `SunBirth` и таблицы `sky.wea`. + +## Купол + +Оригинальный `CSky` строит конечный купол, а не кубическую skybox. Значения +конструктора, подтверждённые в Terrain, такие: высота `10000`, +`theta_max = pi / 4`, вертикальный масштаб `1`, `16` азимутальных секторов и +`5` колец. Радиус вычисляется как + +```text +radius = height * 0.5 / sin(theta_max * 0.5)^2 +``` + +Вершина с индексом 0 находится в зените `[0, 0, height * z_scale]`. +Остальные вершины идут в порядке `azimuth`, затем `ring`; для них + +```text +phi = azimuth / azimuth_count * 2*pi +theta = (ring + 1) / rings * theta_max +x = radius * sin(theta) * sin(phi) +y = radius * sin(theta) * cos(phi) +z = (radius * cos(theta) + height - radius) * z_scale +``` + +На сектор приходится один треугольник веера и по два треугольника на каждую +пару соседних колец. Текстурные координаты трёх исходных стадий используют +масштабы `[1, 15, 3]` для `position.x / radius` и `position.y / radius`. +Цвет зенита и первого пояса берётся из palette 12. Следующие пояса используют +группы palette 8..11, 4..7 и 0..3 с интерполяцией по азимуту. + +Позиции, signed normal bytes, UV и индексы создаются один раз через +`SkyGeometryConfig::mesh`/`SkySystem::new`. Последующие кадры меняют только +цвета вершин методом `SkyMesh::update_colors`; индексы и topology не +пересоздаются. Ошибка конфигурации возвращается как `SkyGeometryError`, а +ошибка входного кадра — как `SkyUpdateError`; они не превращаются в пустой +буфер. Рендерер может загрузить `SkySystem::mesh()` в статический +vertex/index buffer и обновлять только небольшой цветовой диапазон. + +Нормали купола повторяют native `Terrain47BFF` contract: каждый компонент +хранится как signed `i8`, округляется режимом nearest-even и умножается на +`127.0`. `SkyVertex::normal_vector()` декодирует эти bytes делением на `127`; +зенит имеет `[0, 0, 127]`. Вершинный backend должен передавать signed byte +representation без трактовки его как unsigned `0..255`. + +## Облака, звёзды и спрайты + +`sky.wea` — позиционная таблица. В стандартном AutoDemo строки имеют роли: + +| Строка | Роль | Имя в AutoDemo | +|---:|---|---| +| 0 | фон/туманность | `ENV_NEBULA_0` | +| 1 | звёзды | `ENV_STARS` | +| 2 | облака | `ENV_CLOUDS` | +| 3 | солнце | `ENV_SUN_3` | +| 4 | луна | `ENV_MOON` | +| 5 | первый flare | `ENV_FLARE_00` | +| 6 | второй flare | `ENV_FLARE_01` | +| 7 | снежинка | `SNOWFLAKE` | +| 8 | капля дождя | `RAIN_DROP` | + +Это наблюдаемые строки, а не глобальные имена, на которые можно полагаться в +миссии. `SkyMaterials::parse` сохраняет числовой id и имя каждой строки; +миссия может заменить любой материал. `SkyLayerKind` и `SkyLayerFrame` дают +роль, позиционную строку, активность, intensity, UV stage и фазу времени. + +Нативный порядок купола находится в `SkyFrame::passes`: экранный gradient, +nebula row 0 со stars row 1 как второй texture (material mode 4), dome +gradient и clouds row 2. Для nebula/stars/clouds используются UV scales +`1/15/3`; clouds получают translation Z `-5000` относительно камеры и +directional RGB из `sky.packed[0]`. Это четыре фиксированных pass, а не три +независимых цветных слоя. + +Цвет первого screen-gradient и последнего пояса вычисляет +`SkyFrame::screen_gradient(yaw_radians, glare_rgb_delta)`. Native `CSky` сначала +округляет `(yaw + pi) * 180 / pi` режимом nearest-even, выбирает сектор +`(floor(degrees / 90) - 2) mod 4`, а затем смешивает соседние цвета +`SkySample::colors[0..4]`. Остаток сектора умножается на точный коэффициент +из PE `65F64` (`0x3C360B61`, примерно `0.01113567`), alpha результата равна +`255`. `glare_rgb_delta` — входной RGB delta от light manager: для каждого +канала это `max(current_primary_light_rgb - base_sun_rgb, 0)`. Каждый компонент +затем преобразуется так: `d <= 1 -> d*0.8`, `1 < d <= 3 -> d*0.1 + 0.7`, +`d > 3 -> 1`. +`SkyGradientFrame::horizon_floor` задаёт RGB minimum для dome palette, а +`clamp_dome_color` применяет его, сохраняя alpha исходного цвета. + +Солнце, луна и две строки flare являются направленными экранными слоями. +Нативный quad использует UV +`[[.005,.005],[.005,.995],[.995,.995],[.995,.005]]`. Полуразмеры sun/moon в +пикселях вычисляет `sprite_half_size_pixels`: `viewport_width / horizontal_fov +* .325 * .5 * values[0/1]`, где `values` — первые два tail float SKE. +Цвет sprite и его alpha берутся из `SunSample::packed[0]`, отдельно от +directional `SunFrame::color`. + +`SkyFrame::sun_optics` принимает projected sun, viewport, camera forward и +результат world-ray visibility. Он применяет native 15-degree glare cone и +возвращает фиксированные 12 `FlareQuad`; flare slots активны только при +видимом солнце, `length(primary RGB) > 1.1` и cone amount `<= .1`. При cone +`> .1` остаётся только glare boost. Renderer отвечает за сам projection и +occlusion query. + +Дождь, снег и молния используют те же состояния расписания, но их camera-local +геометрия и звуки обновляются `EnvironmentSystem`. Для активного интервала +ресурсы берутся только из ключа, который его запустил. Интенсивность дождя, +снега и молнии — четвёртый trailing float; цвет активной погоды использует +RGB palette 12 с минимумом `80/255` на канал и alpha `150/255`. + +## Туман и свет + +Нативное обновление `CSky` записывает две дальности и один packed RGB. +`FogFrame` повторяет этот контракт: + +```text +fog.start = sky.values[0] * 700 +fog.end = sky.values[1] * 700 +fog.color = RGB(sky.packed[1]) / 255 +``` + +Поля не скрывают порядок исходных параметров: значения сохраняются в +`SkySample`, а преобразование выполняется только в `fog_frame`. Vulkan path +передаёт цвет и дальности в frame uniforms; vertex shader вычисляет линейный +коэффициент по расстоянию до камеры, а fragment shader смешивает RGB материала +с fog color в ветвях combiner, которые используют туман. Конкретный draw state +может обходить это смешивание. + +Цвет primary света солнца — RGB palette 12, нормированный делением на 255 и +умноженный на trailing parameter 2. `SunFrame::direction` — нормированный +вектор после матрицы рождения и направление primary light; sprite/world +geometry использует его противоположность. Второй directional RGB берётся из +packed-параметра 1 без дополнительного умножения intensity. Когда объект +неактивен, sampled поля сохраняются, но оба light slot и sprite inactive. + +В native `CShade` sampled RGB из состояния атмосферы также устанавливает +глобальную нижнюю границу освещения. Для каждого RGB-канала она объединяется +операцией `max` с накопленным цветом материала. Поэтому `SkyFrame` отдаёт +`lighting_floor` из того же sampled packed RGB, а владелец renderer передаёт +его в глобальный light state. Фиксированный ambient-цвет в этом месте меняет +ночной уровень и не соответствует цепочке Terrain → CShade → Ngi32. + +## Бинарный формат `sky.ske` + +Файл little-endian, версия 5. В начале находятся `i32 marker = -1`, +`u32 version = 5` и число дорожек. Дорожка содержит `u32 version = 1`, число +ключей, две даты по 32 байта и ключи. Дата состоит из восьми `u32`; для +планировщика значимы слова 3, 4 и 5 (часы, минуты, секунды), а остальные слова +сохраняются без интерпретации. + +Ключ имеет `u32 version = 3`, дату, raw kind, opaque word, четыре packed ARGB +цвета, два `f32`, пятнадцать packed ARGB цветов, шесть length-prefixed строк, +четыре trailing `f32` и список length-prefixed ссылок. Строка хранится как +`u32 byte_length` и ровно столько байт без завершающего NUL. Неизвестный kind +сохраняется как `AtmosphereKind::Unknown`, как и opaque слова/строки. + +В конце находятся дата и два opaque `u32`. Декодер ограничивает количества и +размеры строк, отклоняет NaN/Infinity во float-полях и требует точного конца +файла. Трейлер выбирает исходную дорожку и позицию час/минута; метод +`initial_offset_seconds()` складывает длительности предыдущих дорожек и +применяет ту же формулу floor. + +Из интерполированного ключа `SkySample` формируется в исходном порядке: + +```text +colors = [header[1], header[2], header[0], header[3], + palette[0], palette[3], palette[1], palette[2], + palette[4], palette[7], palette[5], palette[6], palette[8]] +values = header_values +packed = [palette[11], palette[13]] +``` + +Солнце и луна используют palette 12 и trailing parameter 2 для RGB, +parameters 0 и 1 сохраняются как raw values, а packed-параметры светила — +palette 10 и 14. Kind `0/1` управляет солнцем и луной по имени, `3/4` — +дождём, `5/6` — снегом, `8/9` — молнией; kind `2` и `7` меняют +интерполируемое состояние. Поля, для которых в native renderer ещё нет +проверенного назначения, остаются raw в Rust API и не получают придуманных +значений по умолчанию. diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md new file mode 100644 index 0000000..31024f7 --- /dev/null +++ b/docs/reference/environment-effects.md @@ -0,0 +1,203 @@ +# Эффекты окружения + +Погода в Parkan состоит из двух связанных частей. Файл `sky.ske` задаёт +расписание: когда начинается дождь или снег, как меняются интенсивность и цвет, +какие имена ресурсов принадлежат активному интервалу. Таблица `sky.wea` задаёт +материалы, которыми эти осадки рисуются. `fparkan-fx::environment` соединяет +оба входа с текущей камерой и возвращает один `EnvironmentFrame`. + +```text +sky.ske + -> AtmosphereFrame: время, цвет, интенсивность, ссылки на ресурсы +sky.wea + -> SkyMaterials: имена материалов строк 7 (снег) и 8 (дождь) +камера + EnvironmentSystem + -> EnvironmentFrame: мировые частицы, экранные квадраты, гром и дождевой loop +``` + +Модуль не владеет графическим устройством или звуковой картой. Renderer +разрешает имя материала и отправляет геометрию в прозрачный проход, а audio +backend разрешает архив и имя звука. Это позволяет одной и той же модели +погоды работать в preview и в игровом цикле. + +## Входы и границы + +Для расписания используется такой вызов: + +```rust +let frame = environment.update_atmosphere_with_materials( + dt_seconds, + &atmosphere_frame, + &sky_materials, + camera, +); +``` + +`AtmosphereFrame` передаёт ссылки из стартового ключа активного интервала. +Для дождя первая ссылка используется как звук фонового loop. Строка вида +`archive/name` разделяется на архив и имя. У простой строки `name` архив +остаётся пустым: звуковой владелец подставляет библиотеку, выбранную миссией. +Имя не заменяется глобальным именем из AutoDemo. + +`SkyMaterials` хранит разобранную таблицу, поэтому строки 7 и 8 являются +положением в формате исходной игры, а не зашитыми названиями. В AutoDemo там +находятся `SNOWFLAKE` и `RAIN_DROP`; миссия может передать другие имена. Если +активной погоде не назначен материал, частицы для неё не создаются. + +На входной границе конечные значения приводятся к безопасному диапазону: +`dt_seconds` и интенсивность неотрицательны, интенсивность и каждый компонент +цвета ограничены единицей, а нечисловые значения заменяются нулём. Камера +получает конечную ортонормированную основу, FOV, aspect ratio и размер +viewport от владельца кадра. + +## Объём и движение осадков + +Исходный эмиттер хранит прямоугольный объём в координатах камеры. Его +эталонные параметры таковы: + +```text +near = 2 +far = 50 +half_angle_x = 0.65 +half_angle_y = 0.4875 +``` + +Для эталонной камеры: + +```text +half_x = tan(half_angle_x) * far +half_y = tan(half_angle_y) * far +origin = [near, -half_x, -half_y] +extent = [far - near, 2 * half_x, 2 * half_y] +``` + +Текущий FOV и aspect ratio пересчитывают две боковые грани по той же глубине: + +```text +half_y = tan(vertical_fov / 2) * far +half_x = half_y * aspect_ratio +``` + +Количество частиц зависит от объёма и интенсивности. Вспомогательная +формула использует округление FISTP к ближайшему чётному целому: + +```text +N = round_even( + clamp(current_volume / reference_volume, 0, 1) + * density + * intensity + * 1000 + ) +``` + +Текущий эмиттер передаёт `density = 1`. Публичный +`PrecipitationVolume::particle_count_for` оставляет этот множитель явным для +владельца, который хранит собственную плотность. + +Порядок обновления частицы важен: + +1. при создании три 15-битных значения детерминированного генератора + заполняют локальный объём; +2. локальная точка переводится через основу камеры в мировую позицию; +3. мировая позиция интегрируется с мировым вектором скорости; +4. для проверки границ позиция временно переводится обратно в локальные + координаты, и к каждой оси применяется floor-based wrap; +5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову. + +Скорости не принадлежат системе координат камеры: + +```text +rain = [ 0.5, 0, -60 ] +snow = [ 0.5, 0, -4 ] +``` + +Это мировые векторы. Поворот камеры меняет область появления, wrap и +проекцию, но не вращает уже движущуюся каплю вокруг наблюдателя. В +`EnvironmentPrimitive::Particle` `position`, `velocity`, `world_head` и +`world_tail` имеют мировые координаты. + +Размер частиц состоит из коэффициента класса и масштаба погодного объекта: + +```text +rain_size = scalar * 0.0065 +snow_size = scalar * 0.0195 +``` + +`scalar` вычисляется из той же проекции, которую передаёт владелец кадра: + +```text +horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio) +scalar = viewport_width / horizontal_fov +``` + +Это соответствует запросу размера native camera (`Terrain` primary vtable +slot `+6c`): ширина `RECT` делится на горизонтальный FOV в радианах. Поэтому +`Camera::with_projection(...).with_viewport([width, height])` должен получать +реальный viewport каждого кадра; отдельного setter-а масштаба нет. + +`ScreenBillboard` содержит мировые голову и хвост, их проекцию и четыре NDC +угла с UV. `half_size` остаётся native-величиной в пикселях, а обе глубины +хранятся отдельно; renderer не смешивает эти единицы. Дождь использует +предыдущую мировую голову как хвост и строит полосу перпендикулярно экранному +направлению движения. Снег остаётся квадратом в текущей голове, использует +исходную таблицу знаков и минимальный depth fade `0.1`. Renderer сам решает, +как загрузить этот квадрат и как смешать его материал. + +## Молния и звуковые события + +`decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и +2, первые четыре числа opcode 1 и полный body opcode 1. Имена не подменяются +глобальным whitelist-ом: материал и звук принадлежат выбранному FXID. +`LightningEffect` устанавливается через `set_lightning_effect`; без него +погодный таймер не создаёт визуал или звук. + +Активный объект использует следующий таймер. При интенсивности `I` сначала +применяется `min(I, 0.95)`, затем для случайного `U` из 15-битного диапазона +вычисляется задержка: + +```text +delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U) +``` + +После достижения срока выбираются X и Y из переданных `LightningBounds`, а Z +берётся без изменения. Если границы не заданы, используется позиция камеры. +Следующая попытка разрешена через 6000 миллисекунд. Длительность уже +созданного FX берётся из его заголовка. Renderer получает native descriptor +`[40, 40, 600]`, начало эффекта на `sampled_z + 300`; при нулевом локальном +смещении концы quad находятся на `sampled_z` и `sampled_z + 600`. Opcode 3 +задаёт материал, локальное смещение, scale и lifetime visual quad. Opcode 1 +вычисляется отдельно как динамический point light и не задаёт размеры или UV +quad. +CPU не подменяет numeric поля шириной или линейным lifetime fade. + +Звуки возвращаются рядом с графикой как `SoundEvent`: + +```text +StartLoop -> начать дождевой loop +SetLoopVolume -> обновить его громкость при интерполяции погоды +StopLoop -> остановить loop +OneShot -> воспроизвести разовый звук молнии +``` + +Каждое событие содержит архив, имя, мировую позицию и линейную громкость. +Для FX-звука также передаются native `min_distance`, `max_distance` и +`frequency_ratio`; у дождевого loop используются `0`, бесконечность и `1`. +Событие `OneShot` молнии использует ту же случайно выбранную позицию, что и +визуальный эффект, а его диапазон и частота читаются из opcode 2: в +поставленном `env_lightning` это `100`, `1500` и `1`. Audio backend загружает +объявленный архив и запись лениво, кэширует проверенный sample, создаёт +отдельный spatial source, применяет DirectSound range/pan и текущий listener. +Отсутствие устройства вывода не должно останавливать симуляцию. + +## Что остаётся за владельцем + +`EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы +или звуки и не выполняет финальный GPU pass. Владелец должен передать основу +камеры, projection, реальный viewport, `SkyMaterials` и декодированный FXID. +Из projection и viewport модуль сам получает native scalar осадков. + +CPU-контракт проверяет движение, wrap, количество, время молнии и порядок +звуковых переходов. Он не заявляет совместимость с нераскрытой семантикой +всех FX opcode, с точным native RNG sequence или с конкретным mixer/device +backend. Подробное описание связи с listener и mission audio находится в +[томе VI](../tomes/06-behavior.md#погода-осадки-и-звуковые-события). diff --git a/docs/reference/materials.md b/docs/reference/materials.md index 8146a2c..bfeccf4 100644 --- a/docs/reference/materials.md +++ b/docs/reference/materials.md @@ -38,13 +38,9 @@ MAT0 имеет type ID `0x3054414D`, обычно расположен в `Mate ```c #pragma pack(push, 1) -struct Mat0PrefixV4Plus { +struct Mat0Header { uint16_t phase_count; uint16_t animation_block_count; - uint8_t metadata_a; - uint8_t metadata_b; - uint32_t metadata_c_raw; - uint32_t metadata_d_raw; }; struct Phase34 { @@ -54,8 +50,78 @@ struct Phase34 { #pragma pack(pop) ``` -Versioned fields читаются только если версия их содержит. Для старых версий -используются runtime defaults, а raw values сохраняются. +`attr1` читается из NRes entry metadata и сохраняется вместе с разрешённым +материалом. Это runtime flags материала; его нельзя путать с `attr2`, который +задаёт версию payload. Versioned prefix bytes читаются только если версия их +содержит, а raw values сохраняются для последующих слоёв. + +### Pipeline category + +Call site извлекает native category как `(attr1 >> 2) & 0xF` и передаёт её в +`LegacyPipelineState::with_material_category`. Таблица содержит пять +подтверждённых категорий; helper возвращает `None` для остальных значений и не +меняет уже выбранные depth и cull state. + +| category | source/destination colour factors | alpha test | +| ---: | --- | --- | +| 0 | `ONE / ZERO`, blending disabled | disabled | +| 1 | `SRC_ALPHA / INV_SRC_ALPHA` | enabled, `GREATER_EQUAL` | +| 2 | `SRC_ALPHA / ONE` | enabled, `GREATER_EQUAL` | +| 3 | `ZERO / SRC_COLOR` | enabled, `GREATER_EQUAL` | +| 4 | `DEST_COLOR / SRC_COLOR` | enabled, `GREATER_EQUAL` | + +The mapping comes from the native five render-state pairs selected by the +material category. Alpha reference remains draw-range data, while the Vulkan +pipeline key includes the blend mode, depth mode, cull mode and alpha-test +variant so ranges with different native state do not share a pipeline. + +Каждая phase занимает 34 байта: 18 parameter bytes и 16-byte C string имени +текстуры. Loader переводит параметры в коэффициенты так: + +| bytes | runtime value | +| --- | --- | +| `p0..p2` | additive RGB, `p / 255` | +| `p3` | opacity, `p * 0.01` | +| `p4..p7` | directional values, `p / 255` | +| `p8..p11` | specular values, `p / 255` | +| `p12..p15` | extra values, `p / 255` | +| `p16` | integer power | +| `p17` | signed TEXM page index | + +Texture name и page index выбираются из текущей phase. Они не смешиваются с +соседней phase. + +## Animation + +После phase table идут `animation_block_count` плотных блоков. Каждый блок имеет +`u32 header_raw`, `u16 key_count`, затем `key_count` записей по три `u16`: + +```text +u32 header_raw +u16 key_count +repeat key_count: + u16 phase_index + u16 end_time_ms + u16 raw_k2 +``` + +`header_raw & 7` задаёт режим: `0` loop, `1` ping-pong, `2` clamp, `3` +random-per-query. Значения `4..7` сохраняются как unknown. `header_raw >> 3` +является interpolation mask: bits `1`, `2`, `4`, `8` смешивают группы +`p0..p2`, `p4..p6`, `p8..p10`, `p12..p14`, а bit `16` смешивает `p3`. +Компоненты `p7`, `p11`, `p15`, `p16` и `p17` копируются из текущей phase. + +Время для блока вычисляется как `clock_ms - wear_row_start_ms` с wrapping +`u32` subtraction. Для loop берётся остаток от последнего `end_time_ms`, для +ping-pong нечётный цикл идёт в обратном направлении, clamp после duration +выбирает последнюю phase, а random-per-query использует переданное случайное +значение по модулю duration. Ключ выбирается по интервалу +`previous_end <= local_time < end_time`; следующий ключ циклически замыкается на +первый. На ping-pong turnaround и clamp после duration native routine проходит +общий float interpolation path, включая unsigned subtraction endpoint arithmetic; +это может дать extrapolated coefficients при ненулевой mask. Переданный в +sampler `animation_block_index` уже выбран вызывающим runtime из block table; +packed WEAR handle и номер строки WEAR являются отдельными значениями. ## Fallback diff --git a/docs/reference/msh.md b/docs/reference/msh.md index 2ef276c..165b750 100644 --- a/docs/reference/msh.md +++ b/docs/reference/msh.md @@ -19,7 +19,7 @@ type 10 strings and node names type 13 Batch20 records type 15 auxiliary stream type 17 auxiliary data -type 18 rare stream +type 18 packed UV1 (optional secondary coordinates) type 19 animation frame map type 20 rare auxiliary table ``` @@ -40,23 +40,77 @@ struct Node38 { }; ``` -`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает -отсутствие геометрии для комбинации LOD/group. +В исходном layout это 3 состояния по 5 LOD: `slot_index[state * 5 + lod]`. +Публичные Rust-обёртки пока сохраняют исторические имена `Lod` и `Group`, +поэтому в `selected_slot` аргумент `Lod` задаёт native state (0..2), а +`Group` задаёт native LOD (0..4). `0xFFFF` означает отсутствие геометрии для +комбинации state/LOD. -Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как +Validated `ModelAsset` сохраняет decoded type 8 keys и type 19 map как `ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`, -то есть доказанный static input. `parent_or_link == 0xFFFF` означает root; -иначе это parent index, обязательно меньший индекса child. Этот контракт -подтверждён на тестах анимации с оригинальными ресурсами обеих частей и защищён fallback-ом: -модель с нарушенным порядком не получает придуманную hierarchy. +а `node38_fallback_hierarchy` собирает parent-before-child hierarchy. +`node38_sampled_hierarchy` использует type 19 map в пределах объявленного +числа кадров и интерполирует соседние type 8 keys; при отсутствии usable map +или выходе за frame count используется fallback key. Для покомпонентного +sampling `node38_sampled_hierarchy_at_times` принимает отдельный optional float +time для каждого узла: `Some(time)` выбирает type 19 frame по округлению +`time - 0.5` к ближайшему целому с ties-to-even, затем интерполирует выбранный +type 8 key и его следующий key в исходный float time; `None` использует +fallback key. Значения должны быть конечными и неотрицательными. Это +portable-поведение при default-nearest rounding; полного совпадения со всеми +режимами округления x87 оно не заявляет. `parent_or_link == 0xFFFF` +означает root, иначе это parent index, который должен быть меньше индекса +child. Модель с нарушенным порядком не получает придуманную hierarchy. -В legacy-camera static preview стандартный узел уже получает свой fallback pose -до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation, -затем translation суммируется, а rotations умножаются; после полученной global -pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия -намеренно дублируется на draw-range узла, потому что один source vertex может -быть нарисован разными node poses. Это static fallback hierarchy, а не полная -animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей. +Type 8 записывает quaternion в порядке WXYZ, после decode `AnimKey24` хранит +его как XYZW. Native AniMesh `+0x12560` строит local matrix через NGI32 +`g_FastProc + 0x38`; scalar implementation выдаёт transpose обычной active +`Pose` basis (`M01=2(xy+zw)`, `M02=2(xz-yw)`, `M10=2(xy-zw)`). Поэтому путь +Node38 MSH сопрягает source-local quaternion ровно один раз после sampling и +до hierarchy composition (меняет знаки компонент `x`, `y`, `z`). Для fallback +poses применяется та же граница. Общий animation decoder и другие форматы +анимации не изменяются. + +В legacy-camera static preview без `--static-animation-frame ` используются +сохранённые начальные значения из явной ссылки каждого prototype на `.ctl`. +Узлы без control binding получают time `0`. Для каждой component instance +обрабатываются её CTLD bindings, поэтому общий deduplicated MSH не смешивает +controls разных компонентов. Строки идут в source order, и последняя включённая +строка для одного узла заменяет предыдущую. Flag `0x01` выполняет один wrap +значения blend на единицу, иначе значение ограничивается `[0, 1]`; flag `0x02` +инвертирует результат (`1 - blend`), а строки с `0x04` пропускаются. Выбранный +float time равен `(1 - blend) * frame_a + blend * frame_b`. Если он выходит за +type 19 map, MSH sampler использует fallback key; если после одной wrap/clamp и +invert получается отрицательное или нечисловое время, preview завершится +понятной ошибкой. Явный `--static-animation-frame` остаётся global override +для всех компонентов. Parent pose поворачивает child translation, затем +translation суммируется, а rotations умножаются; после полученной global pose +применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия намеренно +дублируется на draw-range узла, потому что один source vertex может быть +нарисован разными node poses. Это только начальная статическая pose: она не +запускает игровой Control/AI и не обещает полного совпадения с runtime или +всеми x87 rounding modes. + +## Vertex streams + +Основные vertex streams имеют фиксированный source index. У type 3 `attr1` +задаёт количество вершин, а `attr3` — размер записи в байтах. Type 4 +содержит четыре signed bytes нормали. Type 5 и optional type 18 содержат по +два little-endian `uint16` на вершину: + +```c +struct PackedUv16x2 { + uint16_t u; + uint16_t v; +}; +``` + +Type 5 — primary UV0, type 18 — secondary UV1 для lightmap/detail stage. +Для type 18 loader требует `attr3 == 4`, совпадение `attr1` и длины payload, +а также одинаковое число записей с type 3. Обе пары декодируются как +`packed / 1024.0`; renderer обращается к UV1 по тому же `source_index`, что и +к UV0. Отсутствующий type 18 означает, что secondary stage не имеет authored +координат. ## Slot and batch @@ -82,16 +136,20 @@ Type 13 задаёт draw ranges: #pragma pack(push, 1) struct Batch20 { uint16_t batch_flags; - uint16_t material_index; - uint16_t opaque4; - uint16_t opaque6; + uint16_t material_index_hi; // +0x02, native high selector/slot field + uint8_t material_index; // +0x04, WEAR material selector + uint8_t lightmap_index; // +0x05, 0xFF means no lightmap + uint16_t local_batch_index; // +0x06 uint16_t index_count; uint32_t index_start; - uint16_t opaque14; + uint16_t vertex_count; // +0x0E uint32_t base_vertex; }; #pragma pack(pop) ``` -Index check выполняется как `base_vertex + index < vertex_count` для всего -используемого slice. +Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется +отдельно для native overrides. Loader проверяет, что +`index_start + index_count` остаётся внутри type 6, а каждый использованный +индекс через `base_vertex` попадает в position stream. `vertex_count` и +`local_batch_index` доступны renderer для исходного draw contract. diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index ec78020..5add02b 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -69,6 +69,14 @@ projection matrix contract. ## Parity risks +`VulkanStaticCamera::from_legacy_d3d7` keeps the D3D7 view and projection +reconstruction in `fparkan-render`, then negates the vertical projection term +once at the Vulkan adapter boundary. D3D7's top-down viewport places positive +NDC Y at the top; the Vulkan swapchain uses a positive viewport height, so the +adapter flips NDC Y while the shader preserves `gl_Position`. The generic +`from_row_major_view_projection` path remains unchanged; its callers supply +Vulkan-ready matrices, as the free-flight camera already does. + - x87 precision and rounding; - scalar/SIMD `g_FastProc` differences; - object, batch and transparent primitive order; diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 586485b..71cad1d 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -359,9 +359,16 @@ local.set_translation(pose.position); world[n] = world[parent(n)] * local; ``` -Для parity особенно важны x87-compatible округление при выборе frame index и -порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле -границы. +Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа +на CTLD `.ctl`. Используются только включённые строки, а при повторе node ID +поздняя строка заменяет раннюю. Без `--static-animation-frame` preview применяет +эти настройки узлов; узлы без настройки остаются на кадре `0`. Явный +`--static-animation-frame N` переопределяет их и задаёт `N` всем узлам. +Результат — одна статическая поза, не обновление анимации, Control или AI. +MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему +целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий +key в исходном `time`; это portable-вариант native x87 conversion в default +rounding mode, без обещания совпадения при любом x87 control word. Проверки animation data: @@ -611,11 +618,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию. Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и -роботов, ограничения максимального числа, detail level и smoothing. Доказаны -shadow manager/pass, настройки detail/smoothing/count и зависимость от -Terrain/CShade; полная формула projection geometry для каждого caster требует -dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по -именам, а не заменяются произвольными modern defaults. +роботов, ограничения максимального числа, detail level и smoothing. Реализованы +native visibility по шести Vulkan clip-плоскостям с радиусом sphere +100, +порядок кандидатов и лимит 20 до построения страниц, projected-size LOD, +directional/point light contribution, native page layout, raster, smoothing и +receiver projection с edge clipping. Unknown settings из `shade.cfg` читаются и +сохраняются по именам, а не заменяются произвольными modern defaults. Для native ray query +surface descriptor в `+0x10` требует наличия всех переданных bits, а descriptor +в `+0x14` исключает поверхности с любым переданным bit. Общий shadow query +использует full exclusion mask `0x2000`; actor four-ray path получает compact +mask `0x8008`, который landscape boundary разворачивает в full mask +`0x00200020`. Sun path передаёт full `0x20`, кодирует его как compact bit `8` +и затем получает тот же full bit после разворачивания. Hit record хранит в +`+0x20` квадрат евклидовой длины луча, поэтому native four-ray fade использует +`distance² * 1e-4`, а не линейную дальность. Atmosphere manager создаёт world objects для фоновых и погодных явлений. Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain @@ -624,6 +640,9 @@ background sound и обязательные ссылки на lightning effect. требует screen position и occlusion test, rain -- области рядом с observer, sound -- listener. Их нельзя один раз запечь в terrain. +Sun occlusion начинается от точки `camera + 0.5 * normalize(sun - camera)`; +этот offset является частью native query, а не произвольным bias renderer-а. + RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок. Даже правильный средний интервал не даёт повторяемый кадр, если random values запрашиваются в другой последовательности. @@ -677,11 +696,14 @@ settings ID сохраняются. ```text opcode = command_word & 0xFF -enabled = (command_word >> 8) & 1 +native_flag = (command_word >> 8) & 1 ``` -Bits 9-31 являются частью данных и сохраняются. Между командами нет -выравнивания. Размер команды, включая word: +`native_flag` передаётся созданному native command object как режим/feature +flag и не отключает команду. Например, реальный `env_lightning` использует +`native_flag == 0` для opcode 3, opcode 1 и opcode 2, и все три команды +исполняются. Bits 9-31 являются частью данных и сохраняются. Между командами +нет выравнивания. Размер команды, включая word: ```text opcode 1 224 байта @@ -896,8 +918,11 @@ Shade cache в GOG `Terrain.dll` имеет vtable RVA `0x643D0`. Его таб находится по `+320`; банки начинаются с `+332`, их шаг — 212 байт. Lookup RVA `0x10910` возвращает временное представление по `+24`, а не саму запись. Построение в RVA `0x10280` и `0x12e20` использует созданную загрузчиком геометрию. -Поэтому повторить terrain shader только разбором WEAR нельзя. Точная композиция -слоёв и микротекстур остаётся открытым вопросом. +В рабочем Vulkan-пути `TerrainMaterialLayers` разрешает для каждого terrain slot +базовую, detail, overlay и overlay-detail фазы из WEAR/MAT0; их UV, alpha и +lightmap state передаются в `VulkanStaticMaterial`, а shader последовательно +сэмплирует эти четыре стадии. Animation clock обновляет выбранные фазы и +коэффициенты без пересборки terrain mesh, сохраняя native type-14 overlay mask. ## Реализация Vulkan @@ -905,12 +930,23 @@ RVA `0x10910` возвращает временное представление получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через push constant; он не требует нового pipeline. -Статический путь `fparkan-game` загружает ландшафт и MSH-компоненты выбранных -объектов. Локальные material slots разрешаются через WEAR и MAT0 перед -объединением геометрии. Индексы GPU имеют тип `u32`: вся карта может содержать -больше 65 535 вершин, даже если каждый исходный mesh использует `u16`. -Текущий путь берёт базовую текстуру Land2; свет, составные слои и атмосфера -пока не участвуют в этом статическом изображении. +Рабочий путь `fparkan-game` загружает terrain и все выбранные mission roots +(по умолчанию весь список объектов), разрешая локальные material slots через +WEAR и MAT0 до объединения геометрии. Индексы GPU имеют тип `u32`: вся карта +может содержать больше 65 535 вершин, даже если каждый исходный mesh использует +`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. 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` оставляет цикл +бесконечным, а `--preview-roots` служит только диагностическим ограничителем. +`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight +управления. Для сохранения кадра surface должен поддерживать `TRANSFER_SRC`. Renderer копирует последний отправленный swapchain image в host-visible buffer и diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index ebb3a03..a3fa711 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -143,7 +143,7 @@ references каждого record, жёстко ограничивает counts/a файла, но не таблица семантик: названия opcode/words появятся только после handler contracts и runtime traces. -Связь первого header word с dispatch теперь доказана статически: `ai.dll` +Связь первого header word с dispatch доказана статически: `ai.dll` создаёт 73 handler pointers в известном порядке и копирует table без перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо `0xffff_ffff` sentinel; `fparkan-script` отражает это как typed @@ -201,16 +201,6 @@ Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evalu typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций или связь чисел tag с языковыми операторами. -Выгрузка воспроизводится без изменения PE: - -```powershell -& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' ` - C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' ` - -processor x86:LE:32:default ` - -scriptPath C:\Develop\fparkan\tools\ghidra ` - -postScript ExportAiExpressionDispatcher.java -deleteProject -``` - ### TRF и preload-данные TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial @@ -292,9 +282,7 @@ Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI фор а mode передаётся последним; decompiler не восстанавливает предметные имена остальных слов. `InitializeSettings` получает `CreateGameSettings()` из World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA -`0x42478`. Reproducible extractor находится в -`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports -в локальном Ghidra project и не изменяет оригинальную DLL. +`0x42478`. Именно update methods этих private objects, а не пять exports, остаются следующим объектом динамической трассировки. Поэтому reference movement в @@ -330,8 +318,7 @@ interface сразу получает пять virtual calls, связывающ `+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем связывается с `+0x170`. Это достаточное основание хранить будущий Control component как ordered raw-string/resource provenance, но не для присвоения -этим строкам смысловых имён до трассировки private update methods. Extractor: -`tools/ghidra/ExportAniMeshControlCaller.java`. +этим строкам смысловых имён до трассировки private update methods. Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему @@ -393,22 +380,36 @@ Collision manager не должен хранить прямую незащищё ### CTLD и physical resources Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources. -В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять -внутренних счётчиков образуют множество вариантов: наиболее частый размер -392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до -1868 байт и более сложные комбинации. +В заголовке CTLD идут пять `u32`; обозначим первые три counts как `S`, `M` и +`T`. Native layout задаёт начало control-row table формулой +`128 + S * (156 + 16 * M) + 4 * S * S`; за ним следуют `T` records по 36 байт. +В record известны node ID (`i32`, `+0`), два endpoint frame (`f32`, `+4`, `+8`), +начальный blend (`f32`, `+0x0c`) и raw flags (`u32`, `+0x20`). Остальные поля +нельзя выводить из этой позовой привязки. -CTLD является составным count-driven форматом, а не фиксированной struct. -Parser должен: +Пример `fr_l_plant.ctl` имеет counts `[14, 0, 5, 11, 13]`: формула даёт +`row_start = 3096 (0xC18)`, а пять control rows заканчиваются на `+0xCCC`. +GOG `Control.dll` function RVA `0x9950` читает blend из `+0x0c` и flags из +`+0x20`. Его branch RVA `0x99C4..0x9A66` применяет flags: при `flags & 0x1` +blend один раз переносится через границу — из значения выше `1` вычитается +`1`, к значению ниже `0` прибавляется `1`; без этого флага blend ограничивается +`[0, 1]`. Затем `flags & 0x2` инвертирует значение (`1 - blend`). Константы +`Control.dll+0x3B188 = 1.0` и `+0x3B18C = 0.0` подтверждают границы этих +сравнений. -- прочитать prefix и все счётчики с проверкой переполнения; -- вычислить границы секций по их counts; -- сохранять неизвестные records в typed raw containers; -- требовать точного завершения payload; -- не использовать размер одного популярного варианта как универсальный layout. - -Полная предметная семантика всех секций ещё не доказана, но существующие файлы -можно безопасно читать, индексировать и сохранять. +Control в call site RVA `0x99B5` передаёт в AniMesh через slot `+0x30` пару A +`[-1, -1]` и пару B из endpoint полей строки. В call site `0x9A7B` slot `+0x28` +сохраняет нормализованный blend по `+0x114` и weight `1` по `+0x118`. AniMesh +update RVA `0x8BF2..0x8C76` вычисляет +`frame = (1 - blend) * endpoint_a + blend * endpoint_b`; update RVA `0x12560` +выбирает B-state из-за weight `1`. В static preview строки с `flags & 0x4 != 0` +пропускаются до проверки float полей, поскольку endpoint поля в них могут быть +sentinel `-1`. Повторные node ID сохраняют исходный порядок, поэтому последняя +включённая строка задаёт кадр узла. Привязки сохраняются отдельно для каждого +экземпляра компонента, даже когда `PreparedVisual` разделяется через cache. +Это даёт начальную позу preview, но не запускает игровой Control, AI или полный +runtime animation path. Остальные +секции CTLD shape records и contact solver не входят в эту интерпретацию. ### Terrain queries и movement handoff @@ -452,10 +453,8 @@ exports; RVA всех пяти exports изменились. Форматы и c сохранились, но точное physical/collision behavior нельзя считать побайтно тем же. -CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено; -большинство общих CTLD изменено вместе с переработанными моделями. Это -подтверждает count-driven parser, но не закрывает предметную семантику shape -records и contact solver. +Покадровая привязка выше описывает начальную позу компонента. Она не задаёт +семантику CTLD shape records и не восстанавливает contact solver. Differential test обеих частей должен воспроизводить движение без препятствий, slope following, pair collision, timing collision event и удаление объекта в @@ -569,6 +568,142 @@ Ngi32 создаёт низкоуровневый DirectSound backend. `services `ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы: воспроизводят 2D/3D sources, меняют volume и связывают listener с camera. +### Погода, осадки и звуковые события + +Погода для игрока — это одновременно движущиеся точки на экране и звуковой +фон. Расписание `sky.ske` говорит, в какой момент действует дождь, снег или +молния, а `sky.wea` назначает имена материалов. Система окружения каждый +кадр превращает это состояние в два списка: мировые частицы для renderer-а и +звуковые переходы для audio backend-а. + +```text +sky.ske + sky.wea + -> состояние погоды + -> EnvironmentFrame + -> Particle / Lightning (renderer) + -> StartLoop, SetLoopVolume, StopLoop, OneShot (sound) +``` + +Такое разделение нужно для понятной границы ответственности. Система погоды +решает, **что** произошло и где находится источник. Renderer решает, как +нарисовать прозрачный квадрат или молнию. Audio backend разрешает архив и +имя, создаёт источник звука и сравнивает его с текущим listener. + +У осадков есть объём перед наблюдателем. Эталонные границы имеют глубину +`2..50`, половины углов `0.65` и `0.4875` радиана. Для текущей камеры: + +```text +half_y = tan(vertical_fov / 2) * 50 +half_x = half_y * aspect_ratio +``` + +Размер осадков получает тот же camera query, что и native `Terrain`: ширина +viewport делится на горизонтальный FOV в радианах. + +```text +horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio) +precipitation_scalar = viewport_width / horizontal_fov +rain_size = precipitation_scalar * 0.0065 +snow_size = precipitation_scalar * 0.0195 +``` + +Число точек получает масштабирование по отношению текущего объёма к +эталонному и округляется к ближайшему чётному целому: + +```text +N = round_even(clamp(current_volume / reference_volume, 0, 1) + * density * intensity * 1000) +``` + +В рабочем эмиттере `density` равна единице. Точка сначала появляется в +локальных координатах этого объёма, затем получает мировую позицию. Дождь +движется с мировым вектором `[0.5, 0, -60]`, снег — `[0.5, 0, -4]`. +Поворот камеры меняет видимую область и проекцию, но не вращает эти векторы. +Когда точка пересекает грань, она переводится в локальные координаты, +циклически переносится на противоположную грань и возвращается в мир. + +Поэтому источник звука и положение частицы должны храниться в мировых +координатах. У дождя хвостом экранной полосы становится предыдущая мировая +позиция; у снега остаётся квадрат в текущей позиции. Scalar вычисляется из +projection и реального viewport каждого кадра, поэтому отдельная настройка +размера не нужна. + +Дождевой loop следует жизненному циклу состояния: + +| Событие | Действие audio backend | +| --- | --- | +| `StartLoop` | открыть объявленный sample и начать пространственный loop | +| `SetLoopVolume` | сохранить loop и применить новую интенсивность как громкость | +| `StopLoop` | остановить текущий loop | +| `OneShot` | создать отдельный источник и воспроизвести sample молнии один раз | + +Интенсивность между ключами `sky.ske` интерполируется, поэтому `SetLoopVolume` +может приходить на каждом кадре. Имя из расписания сохраняется. Если указано +только `atm_rain1.wav`, архив остаётся пустым и audio owner использует +библиотеку миссии; запись `archive/name` задаёт архив явно. Ресурс загружается +лениво и кэшируется после проверки, чтобы не читать все возможные погодные +звуки при запуске миссии. + +Молния использует отдельный таймер. Для интенсивности `I` и случайного `U` из +15-битного диапазона задержка имеет вид + +```text +delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U) +``` + +После срока выбираются мировые X и Y из `LightningBounds`, а Z копируется из +границ. Затем объект ждёт 6000 миллисекунд перед новой попыткой. Визуальный +контракт передаёт renderer-у material и numeric body opcode 3; native +descriptor `[40, 40, 600]` начинается на `sampled_z + 300`. При нулевом +локальном смещении его концы находятся на `sampled_z` и `sampled_z + 600`. +Opcode 1 в это же время отдельно обновляет point light; он не задаёт размеры +или UV quad. Звуковой `OneShot` использует ту же позицию, а его opcode 2 +параметры `min_distance=100`, `max_distance=1500`, `frequency_ratio=1` +проходят в spatial source. Renderer и audio backend применяют numeric FX +поля и lifetime из заголовка эффекта, а CPU не подменяет их собственной +шириной или fade-кривой. + +Подробные поля `EnvironmentFrame`, правила wrap и границы CPU-модуля собраны +в [справочнике эффектов окружения](../reference/environment-effects.md). + +### Ambient variations и переход день/ночь + +`ambient_music_loop` запускает `THEME` сразу после открытия миссии. Вариации +не выбираются последовательным счётчиком: audio owner получает `dt_seconds` +как приращение времени кадра и после строгого условия `elapsed > delay` +выбирает один sample. +Первый положительный tick поэтому запускает первую вариацию, а задержка между +следующими попытками равна `10 + rand() % 10` секунд. + +В `ambient_music_variation` поддерживаются три независимых пула: + +| Пул | Ключи | Когда выбирается | +| --- | --- | --- | +| default | `DEFAULT_VARIATION1..n` | когда отсутствуют оба пула `DAY` и `NIGHT` | +| day | `DAY_VARIATION1..n` | длина базового RGB активного небесного объекта больше `1.1` | +| night | `NIGHT_VARIATION1..n` | длина базового RGB активного небесного объекта не больше `1.1` | + +Базовый RGB передаётся до camera-dependent glare и берётся у первого активного +небесного объекта. Если существует хотя бы один day/night пул, выбранный +пустой пул остаётся пустым: он не заменяется default или противоположным пулом. +`LIBRARY` у `ambient_music_variation` может отличаться от библиотеки theme; +если поле отсутствует, используется библиотека loop. + +Индекс выбирается двумя 16-битными состояниями Iron3D: + +```text +a = (a << 1) xor b +b = (b >> 1) xor a +index = b % pool_length +``` + +При длине пула больше одного предыдущий индекс отбрасывается одной или более +повторными выборками. Последний индекс сохраняется при переходе между day и +night; для пустого пула sample не создаётся, но следующий таймер продолжает +работать. При пустом пуле native сбрасывает индекс в `-1`. Пауза окна сохраняет +логическое состояние таймера и индекса, поэтому +возобновление не перескакивает на случайную вариацию. + Публичные функции Ngi32: ```text diff --git a/docs/tomes/07-implementation.md b/docs/tomes/07-implementation.md index 7701962..9270bfa 100644 --- a/docs/tomes/07-implementation.md +++ b/docs/tomes/07-implementation.md @@ -28,8 +28,9 @@ FParkan развивается небольшими законченными и Оригинальные ресурсы остаются в установленной игре. Локальные тесты с ними помечены `#[ignore]`, чтобы обычная проверка работала без коммерческих файлов. -Сообщение об ошибке должно назвать ресурс и причину: например, какой материал -сослался на отсутствующую текстуру. Отдельный отчёт для каждого запуска не нужен. +Сообщение об ошибке должно сразу назвать ресурс и причину: например, какой +материал сослался на отсутствующую текстуру. Поэтому диагностика остаётся +частью самого запуска и не зависит от отдельного отчёта. ## Числа и порядок вычислений diff --git a/docs/tomes/08-evidence.md b/docs/tomes/08-evidence.md index 21e3b8d..35bee99 100644 --- a/docs/tomes/08-evidence.md +++ b/docs/tomes/08-evidence.md @@ -241,45 +241,20 @@ near/far mapping, handedness или initial camera selection. Важно, что `ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call stubs и не дают usable ABI. -Live elevated read-only probe впервые подтвердил relocation-aware runtime связь: -`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал -non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно -relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает -live camera object с outer vtable, но одновременно исправляет прежнее слишком -сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix, -а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/ -`0x4D9C0` и exported global ещё не доказаны как один layout без interface -adjustment; их offsets остаются unassigned до recovery selector relationship. +Глобальная camera boundary имеет более точное статическое описание. +`stdGetCurrentCamera2` — короткий getter Terrain по RVA `0x4FD80`, который +читает указатель из global RVA `0x7355C`; initializer по RVA `0x4D4D0` +запрашивает selector `8` у своего `this` и сохраняет полученный interface +pointer. Запрос selector `18` у landscape object проходит через RVA `0x106D0` +и `0x107E0`, возвращает subobject по `base + 0x138`. -Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это -шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA -`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain -по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет -полученный interface pointer. Это доказывает адрес хранения и путь заполнения, -но не разрешает трактовать pointer как конкретный layout камеры либо читать его -как runtime evidence без доступа к процессу на том же уровне привилегий. - -Elevated live sampling теперь доказывает, что direct static xref не исчерпывает -runtime writers: за 25 секунд autoplay global переключился между тремя heap -pointer, все с relocated outer vtables `0x025765B4`/`0x02576558`. У двух объектов -paired blocks `+0x2C/+0x3C/+0x4C` и `+0x6C/+0x7C/+0x8C` синхронно несли -world-like translation, например `(491.562, 761.551, 7.361)`; третий давал -normalized-looking `(0.098, 0.018, 0.856)` и не совпадал с paired block. -Наблюдение согласуется с автоматическими camera switches, но не маркирует mode; -оно запрещает называть `0x4D4D0` единственным runtime writer и требует recovery -indirect/unanalyzed write path. - -Outer vtable `off_100665B4` теперь даёт exact transform adjustment для -world-like sample. Slot `+0x54` вызывает у subobject `outer + 4` slot `+0x20` -с selector `0`, затем копирует returned `+0x0C/+0x1C/+0x2C`. В live объекте -selector field `outer + 0x10` был `0xFFFFFFFF`; реализация selector `0` при -этом возвращает `outer + 0x20`. Значит observed triple -`outer + 0x2C/+0x3C/+0x4C` — доказанная translation часть active affine -transform, а не корреляция. Selector `2` возвращает paired block `outer + 0x60`; -outer slot `+0x70` применяет `atan2` к его axis values, что доказывает -orientation-angle path. Названия полей, angle order и handedness пока не -установлены, но raw affine transform и translation можно сохранять как -backend-neutral camera pose без догадок. +Внешняя camera vtable по RVA `0x665B4` имеет slot `+0x54`, который вызывает у +subobject `outer + 0x4` slot `+0x20` с selector `0` и копирует возвращённые +компоненты `+0x0C`, `+0x1C` и `+0x2C`. При значении selector field +`outer + 0x10 = -1` selector `0` возвращает `outer + 0x20`; selector `2` +возвращает `outer + 0x60`, а slot `+0x70` выводит углы через `atan2`. +Это фиксирует пути чтения положения и ориентации, но не назначает имена полям, +порядок углов или handedness. ### Vtable и interface negotiation @@ -288,6 +263,11 @@ backend-neutral camera pose без догадок. world traversal; camera и viewport получаются через selector-based interface calls; shared objects используют ранний slot как AddRef-подобную операцию. +Запрос selector `18` у landscape object проходит через RVA `0x106D0` и +`0x107E0`, возвращает subobject по `base + 0x138`, а его vtable slot `+0x18` +ведёт через RVA `0x14230` к обработчику `0x127D0`. Это geometry-interface +boundary, отдельная от хранения активной camera. + Правила реконструкции: 1. Зафиксировать byte offset slot и число аргументов. @@ -299,45 +279,6 @@ calls; shared objects используют ранний slot как AddRef-по Нельзя добавлять virtual destructor в начало reconstructed interface: это сдвинет все slots. -### ABI-матрица Частей 1 и 2 - -Во всех пятнадцати DLL совпадают export names, ordinals и import sets. Общее -число exports остаётся 313. Обе полные части содержат 1 134 imported function -slots; значение 1 126 относится к демоверсии и хранится отдельно. - -Побайтно идентичны девять DLL: - -```text -ai.dll -Behavior.dll -Joystick.dll -MisLoad.dll -Net.dll -Ngi32.dll -Terrain.dll -Wizard.dll -World3D.dll -``` - -Пересобраны `AniMesh.dll`, `ArealMap.dll`, `Control.dll`, `Effect.dll`, -`iron3d.dll`, `services.dll`. - -Изменение export RVA: - -```text -AniMesh 2 / 2 -Control 5 / 5 -iron3d 8 / 8 -services 6 / 6 -ArealMap 0 / 9 -Effect 0 / 2 -``` - -Нулевое изменение export RVA не доказывает идентичность тела функции: -`ArealMap.dll` и `Effect.dll` имеют изменённый `.text` при прежних адресах -exports. Compatibility headers фиксируют внешний ABI один раз, но внутренняя -таблица адресов, тестов и semantic deltas выбирается по build fingerprint. - ## Файловая поверхность ### Каталог как внешний API diff --git a/mkdocs.yml b/mkdocs.yml index de6b320..bfd58bd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,8 @@ nav: - WEAR и MAT0: reference/materials.md - Texm: reference/texm.md - Render frame: reference/render-frame.md + - Атмосфера и небо: reference/atmosphere.md + - Эффекты окружения: reference/environment-effects.md - Оригинальные модули: reference/original-binaries.md - Приложения: - Глоссарий: appendices/glossary.md