aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
committerValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
commita8faba8aad441e87f8a8b94c148c01f9c075d621 (patch)
tree97aa4972990edba9991ff2e125edac3a7883f50f
parentaa51f3574d3d4d4e0fa59653eedd718fceb5d1bd (diff)
downloadfparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.tar.xz
fparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.zip
feat: complete map viewer scene and static CTL pose preview
Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
-rw-r--r--README.md51
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag126
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin1184 -> 10388 bytes
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert264
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin1432 -> 18580 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs934
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs39
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs694
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs1570
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs671
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs466
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/tests.rs66
-rw-r--r--adapters/fparkan-render-vulkan/src/policy.rs19
-rw-r--r--apps/fparkan-game/Cargo.toml7
-rw-r--r--apps/fparkan-game/src/audio.rs1798
-rw-r--r--apps/fparkan-game/src/main.rs5822
-rw-r--r--crates/fparkan-animation/src/lib.rs50
-rw-r--r--crates/fparkan-assets/src/lib.rs1126
-rw-r--r--crates/fparkan-fx/src/atmosphere.rs1756
-rw-r--r--crates/fparkan-fx/src/environment.rs2803
-rw-r--r--crates/fparkan-fx/src/lib.rs40
-rw-r--r--crates/fparkan-fx/src/shadow.rs1785
-rw-r--r--crates/fparkan-fx/src/sky.rs2079
-rw-r--r--crates/fparkan-material/src/lib.rs932
-rw-r--r--crates/fparkan-msh/src/lib.rs712
-rw-r--r--crates/fparkan-prototype/src/lib.rs417
-rw-r--r--crates/fparkan-render/src/lib.rs88
-rw-r--r--crates/fparkan-runtime/src/lib.rs6
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs149
-rw-r--r--crates/fparkan-terrain/src/lib.rs73
-rw-r--r--docs/appendices/script-vm.md57
-rw-r--r--docs/index.md7
-rw-r--r--docs/reference/atmosphere.md236
-rw-r--r--docs/reference/environment-effects.md203
-rw-r--r--docs/reference/materials.md80
-rw-r--r--docs/reference/msh.md102
-rw-r--r--docs/reference/render-frame.md8
-rw-r--r--docs/tomes/05-render.md74
-rw-r--r--docs/tomes/06-behavior.md207
-rw-r--r--docs/tomes/07-implementation.md5
-rw-r--r--docs/tomes/08-evidence.md97
-rw-r--r--mkdocs.yml2
42 files changed, 24399 insertions, 1222 deletions
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 <u16>` задаёт один кадр для
+всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
+узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
+используется кадр `0`. Статическая поза не запускает игровой Control, AI или
+полное поведение юнитов.
+`--static-material-phase <u16>` выбирает фиксированную фазу материала.
+`--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
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
Binary files 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
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index 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::<Result<Vec<_>, _>>()?;
@@ -282,6 +313,17 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform(
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<Pose>,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
if !transform
.translation
.iter()
@@ -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::<Result<Vec<_>, _>>()?;
@@ -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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<u16>,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ 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<u16>,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ 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<VulkanNodePose> {
+ 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<VulkanNodePose> {
+ 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<Pose> {
+ 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<u16>,
+ node_index: usize,
+ root_replaced: bool,
+) -> Option<VulkanNodePose> {
+ 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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<fparkan_animation::NodePoseBuffer>,
+ mount_pose: Option<Pose>,
+ hide_root_node: bool,
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<fparkan_animation::NodePoseBuffer> {
+ 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<u32>,
draw_ranges: &mut Vec<VulkanStaticDrawRange>,
) -> 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::<Result<Vec<_>, _>>()?;
@@ -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::<Result<Vec<_>, _>>()?;
@@ -695,6 +1181,38 @@ fn static_terrain_indices(terrain: &LandMeshDocument) -> Result<Vec<u32>, 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<Vec<VulkanStaticDrawRange>, 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]
@@ -1011,6 +1548,65 @@ mod tests {
}
#[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::<Vec<_>>();
+ 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(
vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
@@ -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,
@@ -1113,6 +1712,248 @@ mod tests {
}
#[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(
[1.0, 0.0, 0.0],
@@ -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<const WORD_COUNT: usize>(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<const WORD_COUNT: usize>(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,24 +215,112 @@ pub(super) fn create_static_mesh_vertex_buffer(
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
- let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
- for vertex in &mesh.vertices {
+ let bytes = encode_static_vertices(&mesh.vertices)?;
+ create_host_visible_buffer(
+ instance,
+ device,
+ &bytes,
+ vk::BufferUsageFlags::VERTEX_BUFFER,
+ "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<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
+ 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<Vec<u8>, VulkanSmokeRendererError> {
+ let byte_len = vertices
+ .len()
+ .checked_mul(14)
+ .and_then(|count| count.checked_mul(std::mem::size_of::<f32>()))
+ .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());
}
}
- create_host_visible_buffer(
- instance,
- device,
- &bytes,
- vk::BufferUsageFlags::VERTEX_BUFFER,
- "static mesh vertex buffer",
- )
+ 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::<u8>(), bytes.len());
+ device.device().unmap_memory(buffer.memory);
+ }
+ Ok(())
}
pub(super) fn create_static_mesh_index_buffer(
@@ -235,29 +328,73 @@ pub(super) fn create_static_mesh_index_buffer(
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
- let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
- 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<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
+ 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<Vec<u8>, VulkanSmokeRendererError> {
+ let byte_len = indices
+ .len()
+ .checked_mul(std::mem::size_of::<u32>())
+ .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<VulkanAllocatedImage, VulkanSmokeRendererError> {
+ 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::<f32>()]
+ .copy_from_slice(&value.to_ne_bytes());
+ offset += std::mem::size_of::<f32>();
+ }
+ }
+ 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::<u8>(), 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::<u8>(), 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],
+ &regions,
);
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,
+ &regions,
+ );
+ 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<u8>, 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<u8>,
+ ) -> 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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<bool>],
+ keys: &[Option<f32>],
+) -> Vec<usize> {
+ let mut next = (0..range_count).collect::<Vec<_>>();
+ 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;
@@ -1101,6 +2292,84 @@ mod tests {
}
#[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 = [
(vec![TeardownStep::Instance], vec![TeardownStep::Instance]),
@@ -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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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<Self> {
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::<f32>();
+
+/// 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::<f32>();
+ bytes[offset..offset + std::mem::size_of::<f32>()]
+ .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<VulkanStaticDrawRange>,
}
+/// 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<VulkanStaticTexture>,
+ /// Optional Land1 overlay texture selected by the face tag high byte.
+ pub overlay_texture: Option<VulkanStaticTexture>,
+ /// Optional Land2 detail texture paired with the overlay texture.
+ pub overlay_detail_texture: Option<VulkanStaticTexture>,
+ /// 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<f32>,
+ /// 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<u8>,
+ /// 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<u8>,
}
/// 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<u8>,
+ /// Authored mip levels, including mip zero, when available.
+ ///
+ /// An empty vector requests deterministic CPU generation from `rgba8`.
+ pub mip_levels: Vec<VulkanStaticTextureMip>,
}
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<Vec<usize>, &'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");
}
@@ -312,6 +779,53 @@ 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();
@@ -358,6 +872,54 @@ mod static_mesh_tests {
}
#[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::<f32>();
+ let clip_w = (0..4)
+ .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 3])
+ .sum::<f32>();
+
+ 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<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
+ /// Positional material slots mapped to the owned image list. Fallback
+ /// stages may share one image without coupling independently animated
+ /// material textures.
+ pub(super) texture_slot_indices: Vec<usize>,
pub(super) textures: Vec<VulkanAllocatedImage>,
+ 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<u32>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>,
+ /// Stable range handles used when recording the native pass order.
+ pub(super) draw_order: Vec<usize>,
+ /// Native camera-sort values returned by the draw-item camera callback.
+ pub(super) draw_range_sort_keys: Vec<Option<f32>>,
+ /// 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<Option<bool>>,
+ /// Pipeline state before transparent queue depth policy is applied.
+ pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>,
+ 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<u16>,
+ /// Whether the host-side specular records need a fence-safe GPU upload.
+ pub(super) material_specular_dirty: bool,
+ /// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
+ /// sky nebula/stars texture composite, 3 native lightmap composite.
+ pub(super) material_unlit: Vec<f32>,
+ pub(super) readback_enabled: bool,
pub(super) camera: VulkanStaticCamera,
+ pub(super) frame_uniforms: VulkanFrameUniforms,
pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>,
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<vk::ImageView>,
pub(super) images: Vec<vk::Image>,
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
- pub(super) depth_attachment: VulkanDepthAttachment,
+ /// One depth image per swapchain image so frames can overlap safely.
+ pub(super) depth_attachments: Vec<VulkanDepthAttachment>,
+ /// A render-finished semaphore dedicated to each swapchain image.
+ pub(super) render_finished: Vec<vk::Semaphore>,
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<vk::DescriptorSet>,
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<PipelineKey, vk::Pipeline>,
pub(super) framebuffers: Vec<vk::Framebuffer>,
pub(super) command_buffers: Vec<vk::CommandBuffer>,
}
+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<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
- depth_attachment: Option<VulkanDepthAttachment>,
+ depth_attachments: Vec<VulkanDepthAttachment>,
+ render_finished: Vec<vk::Semaphore>,
render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>,
descriptor_set_layout: Option<vk::DescriptorSetLayout>,
descriptor_pool: Option<vk::DescriptorPool>,
sampler: Option<vk::Sampler>,
+ frame_uniform_buffer: Option<VulkanAllocatedBuffer>,
+ frame_uniform_stride: u64,
+ material_specular_buffer: Option<VulkanAllocatedBuffer>,
+ material_specular_stride: u64,
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
framebuffers: Vec<vk::Framebuffer>,
command_buffers: Vec<vk::CommandBuffer>,
@@ -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<Vec<vk::Framebuffer>, 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<vk::PipelineLayout, VulkanSmokeRendererError> {
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::<f32>()).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::<f32>()).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::<Vec<_>>();
+ 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::<Vec<_>>();
- 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::<Vec<_>>();
+ .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<vk::Pipeline, VulkanSmokeRendererError> {
- 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::<f32>()).unwrap_or(u32::MAX))
+ .stride(u32::try_from(14 * std::mem::size_of::<f32>()).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::<f32>()).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::<f32>()).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::<f32>()).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::<f32>()).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<vk::ShaderModule, VulkanSmokeRendererError> {
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(),
}
);
@@ -480,6 +480,66 @@ fn checked_in_shaders_have_valid_spirv_containers() {
}
#[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!(
validate_spirv_container("triangle.vert", &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0]),
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<VulkanSurfaceFormat> {
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<u32>,
+ },
+ /// 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<String>,
+ /// Ambient day variation names in numeric key order.
+ pub day_variations: Vec<String>,
+ /// Ambient night variation names in numeric key order.
+ pub night_variations: Vec<String>,
+}
+
+#[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<dyn Vfs>,
+ repository: Arc<CachedResourceRepository>,
+ theme: SoundAsset,
+ default_variations: Vec<SoundAsset>,
+ day_variations: Vec<SoundAsset>,
+ night_variations: Vec<SoundAsset>,
+ music: BTreeMap<u8, Arc<[u8]>>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum VariationPool {
+ Default,
+ Day,
+ Night,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct VariationSelection {
+ pool: VariationPool,
+ index: Option<usize>,
+}
+
+/// 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<usize> {
+ 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<VariationSelection> {
+ 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::<f32>()
+ .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<Mutex<NativeSpatialState>>,
+}
+
+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<S>(&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<I>
+where
+ I: Source,
+{
+ input: ChannelVolume<I>,
+ state: Arc<Mutex<NativeSpatialState>>,
+ channel_gains: [f32; 2],
+ refresh_remaining: usize,
+}
+
+const NATIVE_SPATIAL_REFRESH_SAMPLES: usize = 256;
+
+impl<I> NativeSpatialSource<I>
+where
+ I: Source,
+{
+ fn new(input: I, state: Arc<Mutex<NativeSpatialState>>) -> Self {
+ Self {
+ input: ChannelVolume::new(input, vec![0.0, 0.0]),
+ state,
+ channel_gains: [0.0, 0.0],
+ refresh_remaining: 0,
+ }
+ }
+}
+
+impl<I> Iterator for NativeSpatialSource<I>
+where
+ I: Source,
+{
+ type Item = f32;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ 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<I> Source for NativeSpatialSource<I>
+where
+ I: Source,
+{
+ fn current_span_len(&self) -> Option<usize> {
+ 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<std::time::Duration> {
+ 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<dyn Vfs>, mission_cfg: &NormalizedPath) -> Result<Self, AudioError> {
+ 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::<Result<Vec<_>, _>>()?;
+ let day_variations = config
+ .day_variations
+ .iter()
+ .map(|name| load_swav(&repository, &variation_archive, name))
+ .collect::<Result<Vec<_>, _>>()?;
+ let night_variations = config
+ .night_variations
+ .iter()
+ .map(|name| load_swav(&repository, &variation_archive, name))
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(Self {
+ vfs,
+ repository,
+ theme,
+ default_variations,
+ day_variations,
+ night_variations,
+ music: BTreeMap::new(),
+ })
+ }
+
+ fn load_fx_sound(&self, archive: &str, name: &str) -> Result<SoundAsset, AudioError> {
+ 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<Arc<[u8]>, 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<SpatialOneShot>,
+ library: AudioLibrary,
+ ambience: AmbienceScheduler,
+ listener: Listener,
+ paused: bool,
+ dynamic_sounds: BTreeMap<String, SoundAsset>,
+ 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<dyn Vfs>, mission: &str) -> Result<Self, AudioError> {
+ 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<SpatialOneShot, AudioError> {
+ 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<SoundAsset, AudioError> {
+ 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<Cursor<Arc<[u8]>>>;
+
+fn decoder_for(asset: &SoundAsset) -> Result<AudioDecoder, AudioError> {
+ decoder_for_bytes(&asset.resource_name(), &asset.bytes)
+}
+
+fn decoder_for_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result<AudioDecoder, AudioError> {
+ 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<Arc<[u8]>, 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<SoundAsset, AudioError> {
+ 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<NormalizedPath, AudioError> {
+ 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<NormalizedPath, AudioError> {
+ 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<MissionAudioConfig, String> {
+ #[derive(Default)]
+ struct Object {
+ library: Option<String>,
+ theme: Option<String>,
+ default: BTreeMap<usize, String>,
+ day: BTreeMap<usize, String>,
+ night: BTreeMap<usize, String>,
+ }
+
+ 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::<usize>()
+ .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::<usize>()
+ .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::<usize>()
+ .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<String, String> {
+ 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<dyn Vfs> = 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<dyn Vfs> = 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<dyn Vfs> = 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<u16> {
+ 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::path::PathBuf>,
+ ) -> 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::<Vec<_>>();
@@ -56,7 +91,9 @@ fn run(args: &[String]) -> Result<String, String> {
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<String, String> {
.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<String, String> {
&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<Self, String> {
+ 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<Self, String> {
+ 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<Self, String> {
+ 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<KeyCode>, 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<VulkanStaticTexture>,
+ phases: Vec<PreparedMaterialPhase>,
+}
+
+/// 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<usize>,
+ 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<Vec<VulkanStaticTexture>>,
+ overlay: Option<Vec<VulkanStaticTexture>>,
+ overlay_detail: Option<Vec<VulkanStaticTexture>>,
+ applied_material_phases: HashMap<usize, [usize; 3]>,
+}
+
+impl MaterialPhaseBinding {
+ fn sample(
+ &mut self,
+ material_index: usize,
+ clock_ms: u32,
+ ) -> Result<MaterialPhaseSample, String> {
+ 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<usize>,
+ range_indices: Vec<usize>,
+}
+
+#[derive(Clone, Debug)]
+struct MaterialAnimationBinding {
+ initial_phase_index: usize,
+ variants: Vec<MaterialVariantSet>,
+ base: MaterialPhaseBinding,
+ detail: Option<MaterialPhaseBinding>,
+ overlay: Option<MaterialPhaseBinding>,
+ overlay_detail: Option<MaterialPhaseBinding>,
+ stage_textures: Option<MaterialStageTextures>,
+}
+
+#[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<HashMap<usize, ActiveMaterialState>, 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<usize>)> {
+ 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<usize> {
+ 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<ResourceName> {
+ 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<Vec<[f32; 4]>, 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<usize>,
+ secondary_stage: Option<usize>,
+ 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<usize>,
+ 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<MaterialAnimationBinding>,
+ sky_layers: Vec<EnvironmentSkyLayer>,
+ sprites: Vec<EnvironmentSpriteLayer>,
+ 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<RgbaImage>,
+ label: &str,
+) -> Result<VulkanStaticTexture, String> {
+ 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<VulkanStaticMaterial>,
+ material_names: &mut HashMap<String, usize>,
+ material_assets: &HashMap<String, EnvironmentMaterialAsset>,
+ material_animations: &mut Vec<MaterialAnimationBinding>,
+ name: &str,
+ fallback_row: usize,
+) -> Result<usize, String> {
+ 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<VulkanStaticMaterial>,
+) -> Result<usize, String> {
+ 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<VulkanStaticMaterial>) -> Result<usize, String> {
+ 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<VulkanStaticVertex> {
+ [
+ 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<Vec<ShadowTriangle>, 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<ShadowTriangle> {
+ 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<VulkanNodePose>,
+ node1_pose: Option<VulkanNodePose>,
+) -> Option<ShadowComponentSphere> {
+ 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<ShadowTriangle>,
+ component_spheres: &[ShadowComponentSphere],
+) -> Option<ShadowCaster> {
+ if triangles.is_empty() {
+ return None;
+ }
+ let weight = component_spheres
+ .iter()
+ .map(|sphere| sphere.radius)
+ .sum::<f32>();
+ 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<ShadowLight> {
+ 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<ShadowCaster> {
+ 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<VulkanDynamicDrawRange, String> {
+ 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::<Result<Vec<_>, _>>()?,
+ );
+ 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<VulkanStaticVertex>,
+ local_indices: Vec<u32>,
+ pipeline_state: LegacyPipelineState,
+) -> Result<EnvironmentGpuRange, String> {
+ 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<Vec<usize>, 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<Vec<u32>, 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<VulkanStaticVertex> {
+ 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<usize> {
+ 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<VulkanStaticMaterial>,
+ preview_material_animations: &mut Vec<MaterialAnimationBinding>,
+ sun_occlusion_range_indices: &mut Vec<usize>,
+ environment_materials: &SkyMaterials,
+ material_assets: &HashMap<String, EnvironmentMaterialAsset>,
+ sky_mesh: &SkyMesh,
+ camera: &FreeFlightCamera,
+ shadow_range_index: &mut usize,
+ ) -> Result<Self, String> {
+ 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::<Result<Vec<_>, 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::<Vec<_>>();
+ 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::<HashSet<_>>();
+ 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<SkySpriteFrame<'materials>> {
+ 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<f32, String> {
+ 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<String, EnvironmentMaterialAsset>,
+ 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<f32>,
+) -> Result<Option<DynamicEnvironment>, 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::<Result<HashMap<_, _>, 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<PathBuf, String> {
+ 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<Option<PathBuf>, 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::<Vec<_>>()
+ .join(", ")
+ )),
+ }
+}
+
+fn find_sibling_file(source: &Path, name: &str) -> Option<PathBuf> {
+ 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<f32> {
+ 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<VulkanStaticMaterial>,
+ material_animations: Vec<MaterialAnimationBinding>,
+ sun_occlusion_range_indices: Vec<usize>,
+ shadow_casters: Vec<ShadowCaster>,
+ shadow_receivers: Vec<ShadowTriangle>,
+ shadow_material_index: usize,
+ terrain: Arc<TerrainWorld>,
camera: VulkanStaticCamera,
+ free_camera: Option<FreeFlightCamera>,
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<u32>) -> bool {
+ index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO)
+}
+
+fn native_sun_object_kind_eligible(source_type: Option<u32>) -> 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<u32>) -> Option<u32> {
+ 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<Vec<Option<AnimationTime>>, 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<u16>,
+) -> Result<Option<NodePoseBuffer>, 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::<Result<Vec<_>, 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<VulkanStaticXyFrame, String> {
- 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<VulkanStaticMaterial>,
+ material_animations: &mut Vec<MaterialAnimationBinding>,
+ static_material_phase: Option<u16>,
) -> Result<Vec<(u16, u16)>, 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<u16>,
materials: &mut Vec<VulkanStaticMaterial>,
+ material_animations: &mut Vec<MaterialAnimationBinding>,
) -> Result<Vec<(u16, u16)>, 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<VulkanStaticTexture, String> {
+ let mip_images = (0..texture.texm.mip_count())
+ .map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX)))
+ .collect::<Result<Vec<_>, _>>()
+ .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<Vec<VulkanStaticTexture>, 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<MaterialAnimationBinding>>,
+) -> 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<VulkanStaticMaterial>>,
+ lightmap_textures: &[VulkanStaticTexture],
+ mut material_animations: Option<&mut Vec<MaterialAnimationBinding>>,
) -> 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::<Result<Vec<_>, _>>()?,
);
+ // 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, 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<Vec<(VulkanStaticTexture, PreparedMaterialPhase)>, 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<DynamicEnvironment>,
+ audio: Option<audio::GameAudio>,
target_frames: u64,
mission: &str,
object_count: usize,
readback_out: Option<&std::path::Path>,
enable_validation: bool,
+ startup_timings: StartupTimings,
) -> Result<String, String> {
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<VulkanStaticMaterial>,
+ material_animations: Vec<MaterialAnimationBinding>,
+ sun_occlusion_range_indices: Vec<usize>,
+ terrain: Arc<TerrainWorld>,
+ world_range_indices: Vec<usize>,
+ world_range_index_counts: HashMap<usize, u32>,
camera: VulkanStaticCamera,
+ free_camera: Option<FreeFlightCamera>,
+ environment: Option<DynamicEnvironment>,
+ environment_gpu: Option<EnvironmentGpuScene>,
+ shadow_range: EnvironmentGpuRange,
+ shadow_base_vertex: usize,
+ shadow_base_index: usize,
+ shadow_material_index: usize,
+ shadow_casters: Vec<ShadowCaster>,
+ shadow_receivers: Vec<ShadowTriangle>,
+ 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<KeyCode>,
+ last_tick: Instant,
+ mouse_look: bool,
+ audio: Option<audio::GameAudio>,
camera_mode: &'static str,
mesh_components: usize,
terrain_components: usize,
@@ -525,6 +4648,7 @@ struct StaticVulkanApp {
object_count: usize,
readback_out: Option<PathBuf>,
enable_validation: bool,
+ startup_timings: StartupTimings,
window_id: Option<WindowId>,
window: Option<Window>,
renderer: Option<VulkanSmokeRenderer>,
@@ -536,16 +4660,64 @@ struct StaticVulkanApp {
impl StaticVulkanApp {
fn new(
preview: StaticPreviewScene,
+ environment: Option<DynamicEnvironment>,
+ environment_gpu: Option<EnvironmentGpuScene>,
+ shadow_range: EnvironmentGpuRange,
+ shadow_base_vertex: usize,
+ shadow_base_index: usize,
+ audio: Option<audio::GameAudio>,
target_frames: u64,
mission: &str,
object_count: usize,
readback_out: Option<PathBuf>,
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<usize, u32>,
+ active_materials: &HashMap<usize, ActiveMaterialState>,
+ 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<Vec<(usize, f32)>, 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<ShadowCamera>,
+ ) -> Result<ShadowFrameEvidence, String> {
+ 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<f32>,
validation: bool,
readback_out: Option<PathBuf>,
preview_roots: NonZeroUsize,
@@ -772,7 +5369,11 @@ impl Args {
fn parse(args: &[String]) -> Result<Self, String> {
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::<f32>()
+ .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<String, String> {
}
fn usage() -> String {
- "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]".to_string()
+ "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally.".to_string()
}
#[cfg(test)]
@@ -962,6 +5574,314 @@ mod tests {
}
#[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<dyn fparkan_vfs::Vfs> = 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::<Vec<_>>()
+ );
+ 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::<Vec<_>>(),
+ [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!(
Args::parse(&strings(&[
@@ -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,
@@ -987,6 +5908,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(&[
"--root",
@@ -1045,6 +6261,61 @@ mod tests {
}
#[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::<Vec<_>>();
+ 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(&[
"--root",
@@ -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<u8>), Result<fparkan_material::WearTable, String>>;
+/// 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<RgbaImage, String> {
+) -> Result<(Vec<RgbaImage>, 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<RgbaImage>, 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<PathBuf, WearTable>,
+ materials: HashMap<(PathBuf, u16), ResolvedMaterial>,
+ loaded: HashMap<(PathBuf, u16, u16), (Vec<RgbaImage>, 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<RgbaImage>, 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<Mat0Document, String> {
+ 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<ResolvedMaterial, String> {
+ 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<(Vec<u8>, Vec<RgbaImage>, 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<(Vec<u8>, Vec<RgbaImage>, 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<R: ResourceRepository>(
+ repository: &R,
+ material: &ResolvedMaterial,
+ phase_index: u16,
+) -> Result<(Vec<RgbaImage>, 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::<Vec<_>>();
+ 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::<Result<Vec<_>, _>>()
+ .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<RgbaImage>, 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<RgbaImage>, 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<Arc<[u8]>, 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<Vec<RgbaImage>, 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::<Result<Vec<_>, _>>()
+ .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<RgbaImage>,
+ page_rects: &[PageRect],
+ page_index: i8,
+) -> Result<Vec<RgbaImage>, 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<PreparedVisual>,
/// Optional mesh resource backing the visual.
pub mesh: Option<ResourceKey>,
+ /// Source registry entry type (`EXTO`/`INTO`/etc.) when preserved.
+ pub source_type: Option<u32>,
/// Prepared model backing the visual, when geometry is present.
pub model_id: Option<AssetId<PreparedModel>>,
/// 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<PreparedControlNodeBinding>,
+}
+
+/// 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<PreparedVisual>,
/// Visual ids available for each mission object index.
pub object_visuals: Vec<Vec<AssetId<PreparedVisual>>>,
+ /// 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<Vec<Option<PreparedComponentControl>>>,
}
impl MissionAssets {
@@ -829,7 +1464,38 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
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<R: ResourceRepository>(
)));
}
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<R: ResourceRepository>(
textures,
visuals,
object_visuals,
+ object_component_controls,
};
let report = AssetPreparationReport {
model_count: assets.models.len(),
@@ -876,9 +1546,11 @@ enum PreparedVisualSignature {
archive: Vec<u8>,
name: Vec<u8>,
type_id: Option<u32>,
+ source_type: Option<u32>,
dependency_count: usize,
},
NonGeometric {
+ source_type: Option<u32>,
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<Vec<u8>>,
lightmap_requests: Vec<Vec<u8>>,
}
@@ -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<PreparedVisual, Asse
Ok(PreparedVisual {
id: AssetId::new(id),
mesh,
+ source_type: proto.source_type,
model_id: None,
wear_id: None,
model_nodes: 0,
@@ -1590,6 +2271,9 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
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<R: ResourceRepository>(
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<R: ResourceRepository>(
Ok(Some(Arc::from(bytes.into_owned())))
}
+fn prepare_component_control_from_resource<R: ResourceRepository>(
+ repository: &R,
+ source: ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+) -> Result<Option<PreparedComponentControl>, 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<PreparedComponentControl, AssetError> {
+ 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<ResourceName, AssetError> {
let dot = key
.name
@@ -2114,6 +2916,7 @@ fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, Asse
fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
let mut hasher = StableHasher::default();
+ proto.source_type.hash(&mut hasher);
match &proto.geometry {
PrototypeGeometry::Mesh(key) => {
1_u8.hash(&mut hasher);
@@ -2198,6 +3001,105 @@ mod tests {
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<u8> {
+ 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(&[]);
let mesh = ResourceKey {
@@ -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));
@@ -2735,6 +3746,29 @@ mod tests {
}
#[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();
let repo = repository_with_archives_meta(&[(
@@ -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<u8> {
+ minimal_model_archive_with_nodes(&[])
+ }
+
+ fn minimal_model_archive_with_nodes(nodes: &[u8]) -> Vec<u8> {
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<DecodeError> 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<u8>,
+ /// 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<AtmosphereString>,
+ /// Four trailing floating point values.
+ pub parameters: [f32; 4],
+ /// Length-prefixed resource references.
+ pub references: Vec<AtmosphereString>,
+}
+
+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<AtmosphereKey>,
+ /// 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<u32> {
+ 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<AtmosphereString>,
+}
+
+/// 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<SunBirth>,
+}
+
+/// 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<AtmosphereString>,
+}
+
+/// 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<AtmosphereTrack>,
+ /// 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<Self, AtmosphereError> {
+ 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<AtmosphereEvent> {
+ 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<AtmosphereEvent> {
+ 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<SchedulePoint<'_>> {
+ 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<SkeDate, AtmosphereError> {
+ 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<AtmosphereKey, AtmosphereError> {
+ 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<AtmosphereString, AtmosphereError> {
+ 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<SunBirth>) -> 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<AtmosphereString>) {
+ 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<SunBirth> {
+ 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<u8>) {
+ out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
+ out.extend_from_slice(bytes);
+ }
+
+ fn key(
+ out: &mut Vec<u8>,
+ 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<u8>,
+ 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<u8>)) -> Vec<u8> {
+ 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<u8> {
+ 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<PathBuf>) {
+ 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<String>,
+ /// Material row resolved from the current `sky.wea` table.
+ pub material: Option<String>,
+}
+
+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<I, S>(active: bool, intensity: f32, color: [f32; 4], resources: I) -> Self
+ where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+ {
+ 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<Self> {
+ 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<EnvironmentPrimitive>,
+ /// Dynamic point lights emitted by native Effect opcode 1 commands.
+ pub point_lights: Vec<PointLightFrame>,
+ /// Sound transitions and one-shot events.
+ pub sounds: Vec<SoundEvent>,
+}
+
+/// 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<Self, EnvironmentError> {
+ 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<SoundParameters> {
+ 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<PointLightFrame> {
+ 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<PointLightFrame> {
+ 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<LightningQuadFrame> {
+ 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<LightningQuadFrame> {
+ 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<LightningEffect, EnvironmentError> {
+ 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<FxError> 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<LightningEffect>,
+ lightning_bounds: Option<LightningBounds>,
+ lightning: LightningState,
+ rain_loop_active: bool,
+ rain_loop_resource: Option<String>,
+}
+
+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<ParticleState>,
+}
+
+#[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<EnvironmentPrimitive>,
+ ) {
+ 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<LightningRuntime>,
+}
+
+#[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<LightningBounds>,
+ rng: &mut NativeRng,
+ primitives: &mut Vec<EnvironmentPrimitive>,
+ point_lights: &mut Vec<PointLightFrame>,
+ sounds: &mut Vec<SoundEvent>,
+ ) {
+ 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<EnvironmentPrimitive>,
+ point_lights: &mut Vec<PointLightFrame>,
+ ) {
+ 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<LightningBounds>,
+ 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<EffectResource, EnvironmentError> {
+ 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<String, EnvironmentError> {
+ 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<u32> {
+ 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<f32> {
+ 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::<Vec<_>>();
+
+ 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::<Vec<_>>(),
+ );
+ body[24..36].copy_from_slice(
+ &[2.0_f32, 4.0, 6.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[36..48].copy_from_slice(
+ &[1.0_f32, 0.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[48..60].copy_from_slice(
+ &[0.0_f32, 1.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[60..76].copy_from_slice(
+ &[7.0_f32, 7.0, 10.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[76..92].copy_from_slice(
+ &[0.0_f32, 0.0, 0.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ 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::<Vec<_>>(),
+ );
+ body[48..60].copy_from_slice(
+ &[1.0_f32, 0.0, 0.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[60..72].copy_from_slice(
+ &[1.0_f32; 3]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[96..108].copy_from_slice(
+ &[2.0_f32, 3.0, 1.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[108..120].copy_from_slice(
+ &[4.0_f32, 5.0, 2.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[120..132].copy_from_slice(
+ &[1.0_f32; 3]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ 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<u8> {
+ 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<u8> {
+ 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<FxDocument, FxError> {
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<FxEmission>) -> 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"
@@ -641,6 +648,25 @@ mod tests {
}
#[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);
bytes.extend_from_slice(&99_u32.to_le_bytes());
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<ShadowTriangle>,
+}
+
+/// 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<ShadowCacheSlot>; 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<ShadowFrame, ShadowError> {
+ 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<usize> {
+ 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<usize> {
+ // 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<usize>,
+ threshold: u32,
+ ) -> Option<usize> {
+ 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<usize> {
+ 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<u8>,
+ /// Projected receiver vertices.
+ pub vertices: Vec<ShadowVertex>,
+ /// Triangle-list indices into [`ShadowFrame::vertices`].
+ pub indices: Vec<u32>,
+}
+
+/// 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<ShadowCamera>,
+ /// 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<ShadowFrame, ShadowError> {
+ build_shadow_frame_inner(scene, None)
+}
+
+fn build_shadow_frame_inner(
+ scene: ShadowScene<'_>,
+ mut cache: Option<&mut ShadowPageCache>,
+) -> Result<ShadowFrame, ShadowError> {
+ 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<LightContribution> {
+ 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<ShadowProjection, ShadowError> {
+ 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<f32> {
+ 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<F>(polygon: Vec<ClipVertex>, signed_distance: F) -> Vec<ClipVertex>
+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 &current 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::<u32>(),
+ 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<_>>(),
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ assert_eq!(used.len(), expected_used);
+ let mut owners = used
+ .iter()
+ .map(|&index| cache.slots[index].expect("used slot owner").caster)
+ .collect::<Vec<_>>();
+ 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<ShadowTriangle>,
+ ) -> 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<SkyMesh, SkyGeometryError> {
+ 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<SkyMesh, SkyGeometryError> {
+ 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<SkyMesh, SkyGeometryError> {
+ 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<SkyVertex>,
+ /// Triangle-list indices: one fan triangle and `rings - 1` quads per
+ /// azimuth sector.
+ pub indices: Vec<u32>,
+}
+
+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<SkyMaterialRow>,
+ /// Optional lightmap rows after the `LIGHTMAPS` section.
+ pub lightmaps: Vec<SkyMaterialRow>,
+}
+
+impl SkyMaterials {
+ /// Parses a text `sky.wea` payload.
+ pub fn parse(bytes: &[u8]) -> Result<Self, SkyWeaError> {
+ 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::<usize>()
+ .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::<usize>()
+ .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<I, S>(names: I) -> Self
+ where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+ {
+ 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<Vec<String>> for SkyMaterials {
+ fn from(names: Vec<String>) -> 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<SkyMaterialRow, SkyWeaError> {
+ 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::<i32>()
+ .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, SkyWeaError> {
+ 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<usize>,
+ /// Secondary positional WEA row for the stars texture.
+ pub secondary_material_row: Option<usize>,
+ /// 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<usize>,
+ /// Secondary vertex UV stage.
+ pub secondary_uv_stage: Option<usize>,
+ /// 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<usize>,
+ /// 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<SunOptics, SkyProjectionError> {
+ 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<SkyLayerFrame<'a>>,
+ /// 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<SunOptics, SkyProjectionError> {
+ 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<Self, SkyGeometryError> {
+ 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<SkyFrame<'_>, 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<SkyLayerFrame<'a>> {
+ 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::<Vec<_>>();
+
+ 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::<Vec<_>>(),
+ 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<u32> {
+ 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<ResourceError> 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<WearTable, MaterialError> {
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<MaterialPhaseSample, MaterialError> {
- if document.phases.is_empty() {
+ let Some(first_phase) = document.phases.first() else {
return Err(MaterialError::EmptyMaterial);
+ };
+
+ 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 phase_count = document.phases.len();
- let offset = if profile.random_offset {
- material_random_offset(seed, phase_count)
+
+ 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 {
- 0
+ select_key(keys, local_time_ms)
};
- 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 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<Vec<u8>, 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<Vec<LightmapEntry>, 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;
@@ -814,6 +1010,27 @@ mod tests {
}
#[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];
bytes[0..2].copy_from_slice(&2_u16.to_le_bytes());
@@ -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");
-
- 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:?}");
- }
+ fn material_animation_header_decodes_native_mode_and_mask() {
+ let block = MaterialAnimationBlock {
+ header_raw: (0x1f_u32 << 3) | 3,
+ keys: Vec::new(),
+ bytes: Vec::new(),
+ };
+
+ 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");
-
- let sample = sample_material_phase(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::Clamp,
- random_offset: false,
- },
- 1,
- 0,
- )
- .expect("sample");
+ 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],
+ };
- assert_eq!(sample.phase_index, 1);
- assert_eq!(&sample.texture_raw[..1], b"B");
+ 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_interpolation_mask_affects_only_selected_fields() {
- let mut left = [10_u8; 18];
- let mut right = [20_u8; 18];
- left[1] = 100;
- right[1] = 200;
+ 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 out = interpolate_parameter_bytes(left, right, 0b101, 0.5);
+ let sample = sample_material_phase(&document, 0, 150, 0, 0).expect("sample");
- assert_eq!(out[0], 15);
- assert_eq!(out[1], 100);
- assert_eq!(out[2], 15);
- assert_eq!(out[3], 10);
+ 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_timeline_profile_cases_are_evidence_labeled() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
-
+ 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,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 200,
+ k2: 0,
+ },
+ ];
+ let ping_pong = animation_document((0x1_u32) | (1 << 3), phases.clone(), keys.clone());
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::OneShot,
- random_offset: false,
- },
- &[0, 1, 4],
- 0,
- )
- .expect("one-shot"),
- b"M,0,0,0\nM,1,1,1\nM,4,4,2\n"
+ sample_material_phase(&ping_pong, 0, 100, 0, 0)
+ .expect("turn")
+ .phase_index,
+ 1
);
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::Loop,
- random_offset: false,
- },
- &[0, 1, 4],
- 0,
- )
- .expect("loop"),
- b"M,0,0,0\nM,1,1,1\nM,4,4,1\n"
+ sample_material_phase(&ping_pong, 0, 150, 0, 0)
+ .expect("reverse")
+ .phase_index,
+ 1
);
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::PingPong,
- random_offset: false,
- },
- &[0, 1, 3],
- 0,
- )
- .expect("ping-pong"),
- b"M,0,0,0\nM,1,1,1\nM,3,3,1\n"
+ 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_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,
+ 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 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);
+ 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!(
- material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"),
- before
+ 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_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,
- };
+ 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,
+ },
+ MaterialKey {
+ k0: 4,
+ k1: 20,
+ k2: 0,
+ },
+ ],
+ );
+ assert!(matches!(
+ sample_material_phase(&invalid_ref, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationPhaseIndex { .. })
+ ));
- 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 invalid_times = animation_document(
+ 0,
+ vec![phase.clone()],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 20,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 0,
+ k1: 10,
+ k2: 0,
+ },
+ ],
+ );
+ assert!(matches!(
+ sample_material_phase(&invalid_times, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationKeyTimes { .. })
+ ));
+
+ 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::<u8, usize>::new();
+ let mut interpolation_masks = BTreeSet::<u32>::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<u8> {
- 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<MaterialPhase>,
+ keys: Vec<MaterialKey>,
+ ) -> 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<u8> {
@@ -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<u8>,
+ /// 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<Slot>,
/// Vertex positions.
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
- /// Optional texture coordinates.
- pub uv0: Option<Vec<[i16; 2]>>,
+ /// Optional primary packed texture coordinates from type 5.
+ pub uv0: Option<Vec<[u16; 2]>>,
+ /// Optional secondary packed texture coordinates from type 18.
+ pub uv1: Option<Vec<[u16; 2]>>,
/// Triangle indices.
pub indices: Vec<u16>,
/// 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<Vec<[i8; 4]>>,
- /// Optional texture coordinates.
- pub uv0: Option<Vec<[i16; 2]>>,
+ /// Optional primary packed texture coordinates from type 5.
+ pub uv0: Option<Vec<[u16; 2]>>,
+ /// Optional secondary packed texture coordinates from type 18.
+ pub uv1: Option<Vec<[u16; 2]>>,
}
/// 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<ModelAsset, MshError> {
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<SlotId> {
if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 {
@@ -417,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
})
}
-/// Returns the source fallback pose of a standard node.
+/// Returns the fallback key as a native Node38 source-local pose.
///
/// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before
-/// assembling a runtime hierarchy.
+/// assembling a runtime hierarchy. Its quaternion is converted to match native
+/// AniMesh's quaternion-to-matrix convention.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
@@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
.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<NodePoseBuffer> {
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<NodePoseBuffer> {
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
+ 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<AnimationTime>],
+) -> Option<NodePoseBuffer> {
+ 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<NodePoseBuffer> {
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<NodePo
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
- let key_index =
- if metadata.anim_map_start == u16::MAX || u32::from(frame) >= 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<Pose> {
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<ModelAsset, MshError>
}
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<ModelAsset, MshError>
.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<ModelAsset, MshError>
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<Vec<Slot>, 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<Vec<[f32; 3]>, 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<Vec<Batch>, 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<Vec<[i8; 4]>, Ms
Ok(out)
}
-fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> {
+fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[u16; 2]>, 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<Vec<[u16; 2]>, 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<Vec<Option<String>>, 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<u16, MshError> {
Ok(u16::from_le_bytes(arr))
}
-fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
- 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<i8, MshError> {
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,
@@ -1395,6 +1632,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(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
@@ -1421,6 +1757,44 @@ mod tests {
}
#[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)]);
let document = decode_nested(&build_nres(&[
@@ -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(&[]);
@@ -1472,6 +1847,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]]);
let mut indices = Vec::new();
@@ -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<u8>]) -> Vec<u8> {
+ slots_payload_with_bounds([0.0; 3], 1.0, records)
+ }
+
+ fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec<u8>]) -> Vec<u8> {
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<usize>,
+ /// Direct child record indices in source order.
+ pub children: Vec<usize>,
}
/// 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<u32>,
/// Resource dependencies discovered while resolving this prototype.
pub dependencies: Vec<ResourceKey>,
+ /// 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<ResourceKey>,
}
/// 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<UnitDat, PrototypeError> {
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<UnitDat, PrototypeError> {
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<UnitDat, PrototypeError> {
})
}
+/// 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<Vec<UnitComponentTreeNode>, 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<Option<EffectivePrototype>, 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<u32>,
+ control_resource: Option<ResourceKey>,
) -> Result<Option<EffectivePrototype>, 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<Option<ResourceKey>, 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::<Vec<_>>()
+ .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<u32>,
+ control_resource: Option<ResourceKey>,
) -> 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]
@@ -1407,6 +1600,88 @@ mod tests {
}
#[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::<usize>::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();
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
@@ -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<Self> {
+ 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<LegacyPipelineState> 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<LegacyPipelineState> 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<u8>,
- /// 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<u8>,
}
/// 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<TerrainMaterialSelection> {
- 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<TerrainMaterialSelection> {
+ 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<TerrainMaterialSelection> {
+ 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<LandMeshDocument, TerrainF
validate_slots(nres, slots)?;
let positions = parse_positions(nres, positions)?;
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
- let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
+ let uv0 = parse_u16x2_stream(nres, TYPE_UV0, uv0)?;
let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
@@ -683,6 +710,11 @@ pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFo
let payload = nres.payload(entry.id())?;
let areal_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ checked_count_bytes(
+ u64::from(meta.attr1),
+ u64::try_from(AREAL_PREFIX_SIZE).map_err(|_| TerrainFormatError::IntegerOverflow)?,
+ payload.len() as u64,
+ )?;
let mut cursor = Cursor::new(payload);
let mut areals = Vec::with_capacity(areal_count);
for area_index in 0..areal_count {
@@ -1072,11 +1104,11 @@ fn parse_i8x4_stream(
.collect())
}
-fn parse_i16x2_stream(
+fn parse_u16x2_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
-) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
+) -> Result<Vec<[u16; 2]>, 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<u16, TerrainFormatError> {
Ok(u16::from_le_bytes([raw[0], raw[1]]))
}
-fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
- 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<u32, TerrainFormatError> {
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()
+ .overlay_selection()
+ .expect("overlay")
+ .phase_key(),
+ 0x0000_0001
);
assert_eq!(
document.faces[0]
.material_layers()
- .land2_selection()
+ .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]
@@ -1638,6 +1690,21 @@ mod tests {
}
#[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");
let document = decode_land_map(&nres).expect("land map");
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<Option<SurfaceHit>, 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<Option<SurfaceHit>, TerrainError>;
}
/// Navigation query.
@@ -405,6 +422,28 @@ impl SurfaceQuery for TerrainWorld {
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
+ self.raycast_filtered(origin, direction, mask, FullSurfaceMask(0))
+ }
+
+ fn raycast_excluding(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ include: FullSurfaceMask,
+ exclude: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, 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<Option<SurfaceHit>, TerrainError> {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
}
@@ -413,7 +452,10 @@ impl SurfaceQuery for TerrainWorld {
let mut best: Option<SurfaceHit> = 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`. Следовательно, найденные `<base>_Start` и
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
-находится в отдельном позднем update path. Воспроизводимый read-only extractor:
-`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
-
-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`: его
+находится в отдельном позднем update path.
+
+Во всех 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 нормализует
`<base>.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.
-
-В 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 остаётся отдельной задачей.
+а `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.
+
+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 <u16>` используются
+сохранённые начальные значения из явной ссылки каждого 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 является составным count-driven форматом, а не фиксированной struct.
-Parser должен:
-
-- прочитать prefix и все счётчики с проверкой переполнения;
-- вычислить границы секций по их counts;
-- сохранять неизвестные records в typed raw containers;
-- требовать точного завершения payload;
-- не использовать размер одного популярного варианта как универсальный layout.
-
-Полная предметная семантика всех секций ещё не доказана, но существующие файлы
-можно безопасно читать, индексировать и сохранять.
+В заголовке 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`). Остальные поля
+нельзя выводить из этой позовой привязки.
+
+Пример `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` подтверждают границы этих
+сравнений.
+
+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.
-
-Локальная 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 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`.
+
+Внешняя 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