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.
This commit is contained in:
2026-10-11 13:12:50 +04:00
parent aa51f3574d
commit a8faba8aad
42 changed files with 24411 additions and 1234 deletions
+42 -9
View File
@@ -1,8 +1,12 @@
# FParkan # FParkan
Открытый движок *Паркан: Железная стратегия* на Rust и Vulkan. Он читает Открытый движок *Паркан: Железная стратегия* на Rust и Vulkan. Он читает
ресурсы установленной оригинальной игры. Сейчас приложение загружает миссию ресурсы установленной оригинальной игры. Сейчас приложение показывает
и показывает её ландшафт и модели в статическом окне Vulkan. интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты
объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
события. Это просмотр карты с полной графической сценой; прохождение миссии и
игровое поведение юнитов пока не запускаются, а модели показываются в статической
позе, заданной сохранёнными настройками узлов прототипа.
## Запуск ## Запуск
@@ -10,16 +14,45 @@
репозиторий не входят. репозиторий не входят.
```powershell ```powershell
cargo run -p fparkan-game -- ` cargo run --release -p fparkan-game -- `
--root 'C:\GOG Games\Parkan - Iron Strategy' ` --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` читает сохранённую матрицу камеры, корневые объекты миссии (`--preview-roots N` ограничивает их число только для
`--static-animation-frame` и `--static-material-phase` выбирают позу и фазу диагностики).
материала. `--readback-out target/frame.raw` сохраняет последний кадр GPU. `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`.
## Разработка ## Разработка
@@ -2,18 +2,132 @@
layout(location = 0) in vec3 in_color; layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv; 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(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_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(std140, set = 0, binding = 4) uniform FrameConstants {
layout(offset = 64) mat4 clip_from_world;
float alpha_cutoff; vec4 directional_direction[4];
} alpha_test; 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() { void main() {
out_color = texture(base_color, in_uv) * vec4(in_color, 1.0); vec2 base_uv = vec2(material.base_page_x, material.base_page_y)
if (out_color.a < alpha_test.alpha_cutoff) { + 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; discard;
} }
} }
@@ -2,17 +2,273 @@
layout(location = 0) in vec3 in_position; layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_color; 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 = 0) out vec3 out_color;
layout(location = 1) out vec2 out_uv; 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; 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() { void main() {
out_color = in_color; out_color = in_color;
out_uv = in_uv; 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;
} }
File diff suppressed because it is too large Load Diff
+28 -11
View File
@@ -21,16 +21,22 @@ mod swapchain_resources;
mod validation; mod validation;
pub use self::asset_mesh::{ 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_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, 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,
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,
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_world_space_with_transform,
project_msh_to_static_mesh_in_xy_frame, 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_fallback_poses,
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError, project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
VulkanStaticXyFrame, VulkanNodePose, VulkanStaticXyFrame,
}; };
pub use self::capabilities::{ pub use self::capabilities::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, 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::instance::{cstring_vec, ensure_instance_extensions_available};
use self::resources::{ use self::resources::{
color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync, 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_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer,
create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image, create_static_texture_image, create_vertex_buffer, destroy_allocated_buffer,
destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage, destroy_allocated_image, destroy_depth_attachment, readback_buffer_bytes,
VulkanDepthAttachment, VulkanFrameSync, 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::{ pub use self::runtime::{
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request, create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
}; };
pub use self::smoke_types::{ pub use self::smoke_types::{
VulkanDirectionalLight, VulkanDynamicDrawRange, VulkanFrameUniforms, VulkanPointLight,
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, 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)] #[cfg(test)]
use self::surface::extension_name; use self::surface::extension_name;
@@ -72,7 +84,8 @@ pub use self::swapchain::{
VulkanSwapchainProbeError, VulkanSwapchainReport, VulkanSwapchainProbeError, VulkanSwapchainReport,
}; };
use self::swapchain_resources::{ 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 self::validation::{create_validation_messenger, VulkanValidationMessenger};
use ash::vk; 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] { 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 words = [0_u32; WORD_COUNT];
let mut index = 0; let mut index = 0;
while index < WORD_COUNT { while index < WORD_COUNT {
@@ -484,10 +499,12 @@ const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT]
words words
} }
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] = const TRIANGLE_VERTEX_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.vert.spv");
spirv_words(include_bytes!("../shaders/triangle.vert.spv")); const TRIANGLE_FRAGMENT_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.frag.spv");
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] = static TRIANGLE_VERTEX_SHADER_DATA: [u32; TRIANGLE_VERTEX_SHADER_BYTES.len() / 4] =
spirv_words(include_bytes!("../shaders/triangle.frag.spv")); 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_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA;
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA; pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA;
@@ -18,6 +18,9 @@ pub(super) struct VulkanAllocatedImage {
pub(super) image: vk::Image, pub(super) image: vk::Image,
pub(super) memory: vk::DeviceMemory, pub(super) memory: vk::DeviceMemory,
pub(super) view: vk::ImageView, 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. /// Depth/stencil image and the exact format selected for its render pass.
@@ -134,6 +137,9 @@ pub(super) fn create_depth_attachment(
image, image,
memory, memory,
view, view,
width: extent.0,
height: extent.1,
mip_levels: 1,
}, },
format, format,
}) })
@@ -186,7 +192,6 @@ fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags {
pub(super) struct VulkanFrameSync { pub(super) struct VulkanFrameSync {
pub(super) image_available: vk::Semaphore, pub(super) image_available: vk::Semaphore,
pub(super) render_finished: vk::Semaphore,
pub(super) fence: vk::Fence, pub(super) fence: vk::Fence,
} }
@@ -210,17 +215,7 @@ pub(super) fn create_static_mesh_vertex_buffer(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh, mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { ) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>()); let bytes = encode_static_vertices(&mesh.vertices)?;
for vertex in &mesh.vertices {
for value in vertex
.position
.into_iter()
.chain(vertex.color)
.chain(vertex.uv)
{
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
create_host_visible_buffer( create_host_visible_buffer(
instance, instance,
device, device,
@@ -230,34 +225,176 @@ pub(super) fn create_static_mesh_vertex_buffer(
) )
} }
/// Allocates a vertex buffer for a frame's retained mesh capacity.
pub(super) fn create_vertex_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
vertices: &[super::VulkanStaticVertex],
) -> Result<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());
}
}
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( pub(super) fn create_static_mesh_index_buffer(
instance: &VulkanInstanceProbe, instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh, mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { ) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>()); create_index_buffer(instance, device, &mesh.indices)
for &index in &mesh.indices { }
bytes.extend_from_slice(&index.to_ne_bytes());
} /// 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( create_host_visible_buffer(
instance, instance,
device, device,
&bytes, &bytes,
vk::BufferUsageFlags::INDEX_BUFFER, 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( pub(super) fn create_static_texture_image(
instance: &VulkanInstanceProbe, instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool, command_pool: vk::CommandPool,
texture: &VulkanStaticTexture, texture: &VulkanStaticTexture,
) -> Result<VulkanAllocatedImage, VulkanSmokeRendererError> { ) -> 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( let staging = create_host_visible_buffer(
instance, instance,
device, device,
&texture.rgba8, &mip_bytes,
vk::BufferUsageFlags::TRANSFER_SRC, vk::BufferUsageFlags::TRANSFER_SRC,
"static texture staging buffer", "static texture staging buffer",
)?; )?;
@@ -269,7 +406,7 @@ pub(super) fn create_static_texture_image(
height: texture.height, height: texture.height,
depth: 1, depth: 1,
}) })
.mip_levels(1) .mip_levels(mip_levels)
.array_layers(1) .array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1) .samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL) .tiling(vk::ImageTiling::OPTIMAL)
@@ -336,6 +473,7 @@ pub(super) fn create_static_texture_image(
image, image,
texture.width, texture.width,
texture.height, texture.height,
mip_levels,
) { ) {
// SAFETY: Both resources were created on this device and are being rolled back once. // SAFETY: Both resources were created on this device and are being rolled back once.
unsafe { unsafe {
@@ -350,7 +488,7 @@ pub(super) fn create_static_texture_image(
.image(image) .image(image)
.view_type(vk::ImageViewType::TYPE_2D) .view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM) .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. // SAFETY: The image is live, initialized and has the stated color subresource.
let view = { let view = {
// SAFETY: The image is live, initialized and has the stated color subresource. // SAFETY: The image is live, initialized and has the stated color subresource.
@@ -371,9 +509,211 @@ pub(super) fn create_static_texture_image(
image, image,
memory, memory,
view, 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( fn upload_static_texture(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool, command_pool: vk::CommandPool,
@@ -381,6 +721,7 @@ fn upload_static_texture(
image: vk::Image, image: vk::Image,
width: u32, width: u32,
height: u32, height: u32,
mip_levels: u32,
) -> Result<(), VulkanSmokeRendererError> { ) -> Result<(), VulkanSmokeRendererError> {
let allocate_info = vk::CommandBufferAllocateInfo::default() let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(command_pool) .command_pool(command_pool)
@@ -407,7 +748,7 @@ fn upload_static_texture(
result, result,
}); });
} }
let range = color_subresource_range(); let range = color_subresource_range(mip_levels);
let to_transfer = vk::ImageMemoryBarrier::default() let to_transfer = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::UNDEFINED) .old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
@@ -415,19 +756,34 @@ fn upload_static_texture(
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE) .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.image(image) .image(image)
.subresource_range(range); .subresource_range(range);
let region = vk::BufferImageCopy::default() 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( .image_subresource(
vk::ImageSubresourceLayers::default() vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR) .aspect_mask(vk::ImageAspectFlags::COLOR)
.mip_level(0) .mip_level(mip_level)
.base_array_layer(0) .base_array_layer(0)
.layer_count(1), .layer_count(1),
) )
.image_extent(vk::Extent3D { .image_extent(vk::Extent3D {
width, width: mip_width,
height, height: mip_height,
depth: 1, 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() let to_sampled = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
@@ -451,7 +807,7 @@ fn upload_static_texture(
staging_buffer, staging_buffer,
image, image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL, vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[region], &regions,
); );
device.device().cmd_pipeline_barrier( device.device().cmd_pipeline_barrier(
command_buffer, 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( pub(super) fn destroy_allocated_image(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
image: &VulkanAllocatedImage, image: &VulkanAllocatedImage,
@@ -624,36 +1220,24 @@ pub(super) fn create_frame_sync(
let mut sync = Vec::with_capacity(2); let mut sync = Vec::with_capacity(2);
for _ in 0..2 { for _ in 0..2 {
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown. // 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) } let image_available =
.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.
match unsafe { device.device().create_semaphore(&semaphore_info, None) } { match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
Ok(render_finished) => render_finished, Ok(image_available) => image_available,
Err(error) => { Err(error) => {
destroy_frame_sync_objects(device, &sync); 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 { return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSemaphore(render_finished)", context: "vkCreateSemaphore(image_available)",
result: error, result: error,
}); });
} }
}
}; };
// SAFETY: The fence belongs to this live logical device and is destroyed at teardown. // 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) } { let fence = match unsafe { device.device().create_fence(&fence_info, None) } {
Ok(fence) => fence, Ok(fence) => fence,
Err(error) => { Err(error) => {
destroy_frame_sync_objects(device, &sync); destroy_frame_sync_objects(device, &sync);
// SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure. // SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
unsafe { unsafe { device.device().destroy_semaphore(image_available, None) };
device.device().destroy_semaphore(image_available, None);
device.device().destroy_semaphore(render_finished, None);
}
return Err(VulkanSmokeRendererError::VulkanOperation { return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateFence", context: "vkCreateFence",
result: error, result: error,
@@ -662,7 +1246,6 @@ pub(super) fn create_frame_sync(
}; };
sync.push(VulkanFrameSync { sync.push(VulkanFrameSync {
image_available, image_available,
render_finished,
fence, fence,
}); });
} }
@@ -676,9 +1259,6 @@ fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanF
device device
.device() .device()
.destroy_semaphore(frame_sync.image_available, None); .destroy_semaphore(frame_sync.image_available, None);
device
.device()
.destroy_semaphore(frame_sync.render_finished, None);
device.device().destroy_fence(frame_sync.fence, None); device.device().destroy_fence(frame_sync.fence, None);
} }
} }
@@ -738,11 +1318,11 @@ pub(super) fn readback_buffer_bytes(
Ok(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() vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR) .aspect_mask(vk::ImageAspectFlags::COLOR)
.base_mip_level(0) .base_mip_level(0)
.level_count(1) .level_count(level_count)
.base_array_layer(0) .base_array_layer(0)
.layer_count(1) .layer_count(1)
} }
File diff suppressed because it is too large Load Diff
@@ -48,8 +48,20 @@ pub struct VulkanStaticVertex {
pub position: [f32; 3], pub position: [f32; 3],
/// Linear RGB vertex color. /// Linear RGB vertex color.
pub color: [f32; 3], pub color: [f32; 3],
/// Unit vertex normal in world/object coordinates.
pub normal: [f32; 3],
/// Texture coordinate consumed by the static material bridge. /// Texture coordinate consumed by the static material bridge.
pub uv: [f32; 2], 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. /// A static geometry camera represented in the shader's matrix memory order.
@@ -71,7 +83,9 @@ impl VulkanStaticCamera {
projection: LegacyD3d7Projection, projection: LegacyD3d7Projection,
) -> Option<Self> { ) -> Option<Self> {
let view = transform.try_direct3d7_view_row_major()?; 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) 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] { fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16]; let mut result = [0.0; 16];
for row in 0..4 { for row in 0..4 {
@@ -131,6 +381,23 @@ pub struct VulkanStaticMesh {
pub draw_ranges: Vec<VulkanStaticDrawRange>, 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. /// One indexed triangle-list draw retained from an original mesh batch.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanStaticDrawRange { pub struct VulkanStaticDrawRange {
@@ -140,6 +407,12 @@ pub struct VulkanStaticDrawRange {
pub index_count: u32, pub index_count: u32,
/// Original positional `Batch20.material_index` selector. /// Original positional `Batch20.material_index` selector.
pub material_index: u16, 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. /// Backend-neutral fixed-function state for this source range.
pub pipeline_state: LegacyPipelineState, pub pipeline_state: LegacyPipelineState,
/// Alpha-test reference in legacy 0..=255 units; dynamic material data. /// Alpha-test reference in legacy 0..=255 units; dynamic material data.
@@ -147,6 +420,13 @@ pub struct VulkanStaticDrawRange {
} }
impl 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. /// Returns the canonical key used for Vulkan pipeline selection.
#[must_use] #[must_use]
pub fn pipeline_key(self) -> PipelineKey { pub fn pipeline_key(self) -> PipelineKey {
@@ -165,12 +445,75 @@ impl VulkanStaticDrawRange {
} }
/// One diffuse material texture keyed by an original MSH batch selector. /// One diffuse material texture keyed by an original MSH batch selector.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct VulkanStaticMaterial { pub struct VulkanStaticMaterial {
/// Positional material selector used by one or more source batches. /// Positional material selector used by one or more source batches.
pub material_index: u16, pub material_index: u16,
/// Decoded RGBA8 diffuse texture for this selector. /// Decoded RGBA8 diffuse texture for this selector.
pub texture: VulkanStaticTexture, 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. /// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
@@ -182,6 +525,10 @@ pub struct VulkanStaticTexture {
pub height: u32, pub height: u32,
/// Row-major RGBA8 pixels. /// Row-major RGBA8 pixels.
pub rgba8: Vec<u8>, 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 { impl VulkanStaticTexture {
@@ -200,7 +547,46 @@ impl VulkanStaticTexture {
Some(expected) if expected != self.rgba8.len() => { Some(expected) if expected != self.rgba8.len() => {
Err("static texture rgba8 byte count does not match extent") 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> { ) -> Result<Vec<usize>, &'static str> {
for (index, material) in materials.iter().enumerate() { for (index, material) in materials.iter().enumerate() {
material.texture.validate()?; 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] if materials[..index]
.iter() .iter()
.any(|previous| previous.material_index == material.material_index) .any(|previous| previous.material_index == material.material_index)
@@ -247,17 +687,26 @@ impl VulkanStaticMesh {
VulkanStaticVertex { VulkanStaticVertex {
position: [0.0, -0.55, 0.0], position: [0.0, -0.55, 0.0],
color: [1.0, 0.2, 0.2], color: [1.0, 0.2, 0.2],
normal: [0.0, 0.0, 1.0],
uv: [0.5, 0.0], uv: [0.5, 0.0],
detail_uv: [0.0, 0.0],
overlay_alpha: 0.0,
}, },
VulkanStaticVertex { VulkanStaticVertex {
position: [0.55, 0.55, 0.0], position: [0.55, 0.55, 0.0],
color: [0.2, 1.0, 0.2], color: [0.2, 1.0, 0.2],
normal: [0.0, 0.0, 1.0],
uv: [1.0, 1.0], uv: [1.0, 1.0],
detail_uv: [0.0, 0.0],
overlay_alpha: 0.0,
}, },
VulkanStaticVertex { VulkanStaticVertex {
position: [-0.55, 0.55, 0.0], position: [-0.55, 0.55, 0.0],
color: [0.2, 0.4, 1.0], color: [0.2, 0.4, 1.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 1.0], uv: [0.0, 1.0],
detail_uv: [0.0, 0.0],
overlay_alpha: 0.0,
}, },
], ],
indices: vec![0, 1, 2], indices: vec![0, 1, 2],
@@ -265,6 +714,8 @@ impl VulkanStaticMesh {
first_index: 0, first_index: 0,
index_count: 3, index_count: 3,
material_index: 0, material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(), pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0, alpha_test_reference: 0,
}], }],
@@ -275,6 +726,22 @@ impl VulkanStaticMesh {
if self.vertices.is_empty() { if self.vertices.is_empty() {
return Err("static mesh has no vertices"); 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) { if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) {
return Err("static mesh indices must contain complete triangles"); return Err("static mesh indices must contain complete triangles");
} }
@@ -311,6 +778,53 @@ impl VulkanStaticMesh {
mod static_mesh_tests { mod static_mesh_tests {
use super::*; 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] #[test]
fn smoke_triangle_is_valid_complete_geometry() { fn smoke_triangle_is_valid_complete_geometry() {
let mesh = VulkanStaticMesh::smoke_triangle(); let mesh = VulkanStaticMesh::smoke_triangle();
@@ -357,6 +871,54 @@ mod static_mesh_tests {
assert_eq!(camera.clip_from_world[7], 0.65_f32.sin()); assert_eq!(camera.clip_from_world[7], 0.65_f32.sin());
} }
#[test]
fn legacy_camera_maps_world_up_to_top_of_positive_height_vulkan_viewport() {
let transform = RawCameraTransform {
words: [
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1024, 768],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)
.expect("recovered camera inputs are valid");
// The raw identity camera's recovered view maps world +X to depth
// and world +Z to camera up.
let world_up_at_positive_depth = [10.0, 0.0, 1.0, 1.0];
let clip_y = (0..4)
.map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 1])
.sum::<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] #[test]
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() { fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
let identity = VulkanStaticCamera::default().clip_from_world; let identity = VulkanStaticCamera::default().clip_from_world;
@@ -387,6 +949,8 @@ mod static_mesh_tests {
first_index: 0, first_index: 0,
index_count: 2, index_count: 2,
material_index: 0, material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(), pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0, alpha_test_reference: 0,
}], }],
@@ -415,6 +979,8 @@ mod static_mesh_tests {
first_index: 0, first_index: 0,
index_count: 3, index_count: 3,
material_index: 7, material_index: 7,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(), pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0, alpha_test_reference: 0,
}, },
@@ -422,6 +988,8 @@ mod static_mesh_tests {
first_index: 3, first_index: 3,
index_count: 3, index_count: 3,
material_index: 2, material_index: 2,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(), pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0, alpha_test_reference: 0,
}, },
@@ -430,15 +998,58 @@ mod static_mesh_tests {
width: 1, width: 1,
height: 1, height: 1,
rgba8: vec![255; 4], rgba8: vec![255; 4],
mip_levels: Vec::new(),
}; };
let materials = [ let materials = [
VulkanStaticMaterial { VulkanStaticMaterial {
material_index: 2, material_index: 2,
texture: texture(), 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 { VulkanStaticMaterial {
material_index: 7, material_index: 7,
texture: texture(), 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!(disabled.alpha_test_cutoff(), 0.0);
assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.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) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>, pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_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) 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_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>, 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) camera: VulkanStaticCamera,
pub(super) frame_uniforms: VulkanFrameUniforms,
pub(super) frame_sync: Vec<VulkanFrameSync>, pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>, pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize, pub(super) current_frame: usize,
@@ -8,38 +8,85 @@ use fparkan_render::{
use std::collections::BTreeMap; use std::collections::BTreeMap;
use super::{ use super::{
color_subresource_range, create_depth_attachment, destroy_depth_attachment, color_subresource_range, create_depth_attachment, create_material_specular_buffer,
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe, destroy_depth_attachment, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment,
VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES,
VULKAN_MATERIAL_SPECULAR_RECORD_BYTES,
}; };
pub(super) struct VulkanSwapchainResources { pub(super) struct VulkanSwapchainResources {
pub(super) image_views: Vec<vk::ImageView>, pub(super) image_views: Vec<vk::ImageView>,
pub(super) images: Vec<vk::Image>, pub(super) images: Vec<vk::Image>,
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>, 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) render_pass: vk::RenderPass,
pub(super) pipeline_layout: vk::PipelineLayout, pub(super) pipeline_layout: vk::PipelineLayout,
pub(super) descriptor_set_layout: vk::DescriptorSetLayout, pub(super) descriptor_set_layout: vk::DescriptorSetLayout,
pub(super) descriptor_pool: vk::DescriptorPool, pub(super) descriptor_pool: vk::DescriptorPool,
pub(super) descriptor_sets: Vec<vk::DescriptorSet>, pub(super) descriptor_sets: Vec<vk::DescriptorSet>,
pub(super) sampler: vk::Sampler, 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. /// Live graphics pipelines indexed by canonical backend-neutral state.
pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>, pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
pub(super) framebuffers: Vec<vk::Framebuffer>, pub(super) framebuffers: Vec<vk::Framebuffer>,
pub(super) command_buffers: Vec<vk::CommandBuffer>, 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 { struct PartialSwapchainResources {
image_views: Vec<vk::ImageView>, image_views: Vec<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>, readback_buffers: Vec<VulkanAllocatedBuffer>,
depth_attachment: Option<VulkanDepthAttachment>, depth_attachments: Vec<VulkanDepthAttachment>,
render_finished: Vec<vk::Semaphore>,
render_pass: Option<vk::RenderPass>, render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>, pipeline_layout: Option<vk::PipelineLayout>,
descriptor_set_layout: Option<vk::DescriptorSetLayout>, descriptor_set_layout: Option<vk::DescriptorSetLayout>,
descriptor_pool: Option<vk::DescriptorPool>, descriptor_pool: Option<vk::DescriptorPool>,
sampler: Option<vk::Sampler>, 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>, pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
framebuffers: Vec<vk::Framebuffer>, framebuffers: Vec<vk::Framebuffer>,
command_buffers: Vec<vk::CommandBuffer>, command_buffers: Vec<vk::CommandBuffer>,
@@ -53,6 +100,7 @@ pub(super) fn create_swapchain_resources(
vertex_buffer: &VulkanAllocatedBuffer, vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer, index_buffer: &VulkanAllocatedBuffer,
textures: &[VulkanAllocatedImage], textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
draw_ranges: &[super::VulkanStaticDrawRange], draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport, depth_request: DepthStencilSupport,
reuse_command_pool: bool, reuse_command_pool: bool,
@@ -74,16 +122,57 @@ pub(super) fn create_swapchain_resources(
swapchain.report.plan.format.format, swapchain.report.plan.format.format,
)?, )?,
readback_buffers: Vec::new(), readback_buffers: Vec::new(),
depth_attachment: None, depth_attachments: Vec::new(),
render_finished: Vec::new(),
render_pass: None, render_pass: None,
pipeline_layout: None, pipeline_layout: None,
descriptor_set_layout: None, descriptor_set_layout: None,
descriptor_pool: None, descriptor_pool: None,
sampler: None, sampler: None,
frame_uniform_buffer: None,
frame_uniform_stride: 0,
material_specular_buffer: None,
material_specular_stride: 0,
pipelines: BTreeMap::new(), pipelines: BTreeMap::new(),
framebuffers: Vec::new(), framebuffers: Vec::new(),
command_buffers: 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 ( let (
depth_attachment, depth_attachment,
render_pass, render_pass,
@@ -99,8 +188,17 @@ pub(super) fn create_swapchain_resources(
swapchain.report.plan.format.format, swapchain.report.plan.format.format,
swapchain.report.plan.extent, swapchain.report.plan.extent,
textures, textures,
texture_slot_indices,
draw_ranges, draw_ranges,
depth_request, 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, Ok(bundle) => bundle,
Err(error) => { Err(error) => {
@@ -108,19 +206,35 @@ pub(super) fn create_swapchain_resources(
return Err(error); return Err(error);
} }
}; };
let depth_view = depth_attachment.image.view; // Keep every handle from the bundle in the partial owner before creating
partial.depth_attachment = Some(depth_attachment); // 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.render_pass = Some(render_pass);
partial.pipeline_layout = Some(pipeline_layout); partial.pipeline_layout = Some(pipeline_layout);
partial.descriptor_set_layout = Some(descriptor_set_layout); partial.descriptor_set_layout = Some(descriptor_set_layout);
partial.descriptor_pool = Some(descriptor_pool); partial.descriptor_pool = Some(descriptor_pool);
partial.sampler = Some(sampler); partial.sampler = Some(sampler);
partial.pipelines = pipelines; 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( let framebuffers = match create_swapchain_framebuffers(
device, device,
render_pass, render_pass,
&partial.image_views, &partial.image_views,
depth_view, &partial.depth_attachments,
swapchain.report.plan.extent, swapchain.report.plan.extent,
) { ) {
Ok(framebuffers) => framebuffers, Ok(framebuffers) => framebuffers,
@@ -168,24 +282,44 @@ pub(super) fn create_swapchain_resources(
}; };
partial.command_buffers = command_buffers; partial.command_buffers = command_buffers;
let _ = (vertex_buffer, index_buffer); let _ = (vertex_buffer, index_buffer);
let depth_attachment = if partial.depth_attachments.len() != images.len()
partial || partial.render_finished.len() != images.len()
.depth_attachment {
.take() destroy_partial_swapchain_resources(device, command_pool, partial);
.ok_or(VulkanSmokeRendererError::InvariantViolation { return Err(VulkanSmokeRendererError::InvariantViolation {
context: "depth attachment ownership after swapchain setup", context: "swapchain per-image resource count after setup",
})?; });
}
Ok(VulkanSwapchainResources { Ok(VulkanSwapchainResources {
image_views: partial.image_views, image_views: partial.image_views,
images, images,
readback_buffers: partial.readback_buffers, readback_buffers: partial.readback_buffers,
depth_attachment, depth_attachments: partial.depth_attachments,
render_pass, render_finished: partial.render_finished,
pipeline_layout, render_pass: partial
descriptor_set_layout, .render_pass
descriptor_pool, .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, 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, pipelines: partial.pipelines,
framebuffers: partial.framebuffers, framebuffers: partial.framebuffers,
command_buffers: partial.command_buffers, command_buffers: partial.command_buffers,
@@ -210,8 +344,11 @@ fn create_swapchain_pipeline_bundle(
format: i32, format: i32,
extent: (u32, u32), extent: (u32, u32),
textures: &[VulkanAllocatedImage], textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
draw_ranges: &[super::VulkanStaticDrawRange], draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport, depth_request: DepthStencilSupport,
frame_uniform_buffer: &VulkanAllocatedBuffer,
material_specular_buffer: &VulkanAllocatedBuffer,
) -> Result< ) -> Result<
( (
VulkanDepthAttachment, VulkanDepthAttachment,
@@ -234,7 +371,14 @@ fn create_swapchain_pipeline_bundle(
} }
}; };
let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) = 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. // 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) }; unsafe { device.device().destroy_render_pass(render_pass, None) };
destroy_depth_attachment(device, &depth_attachment); destroy_depth_attachment(device, &depth_attachment);
@@ -289,12 +433,23 @@ fn create_swapchain_framebuffers(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass, render_pass: vk::RenderPass,
image_views: &[vk::ImageView], image_views: &[vk::ImageView],
depth_view: vk::ImageView, depth_attachments: &[VulkanDepthAttachment],
extent: (u32, u32), extent: (u32, u32),
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> { ) -> 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()); let mut framebuffers = Vec::with_capacity(image_views.len());
for image_view in image_views.iter().copied() { for (image_view, depth_attachment) in image_views.iter().copied().zip(depth_attachments) {
match create_framebuffer(device, render_pass, image_view, depth_view, extent) { match create_framebuffer(
device,
render_pass,
image_view,
depth_attachment.image.view,
extent,
) {
Ok(framebuffer) => framebuffers.push(framebuffer), Ok(framebuffer) => framebuffers.push(framebuffer),
Err(error) => { Err(error) => {
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure. // 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) .image(image)
.view_type(vk::ImageViewType::TYPE_2D) .view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::from_raw(format)) .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. // 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| { unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation { VulkanSmokeRendererError::VulkanOperation {
@@ -384,10 +539,21 @@ fn create_render_pass(
let dependency = vk::SubpassDependency::default() let dependency = vk::SubpassDependency::default()
.src_subpass(vk::SUBPASS_EXTERNAL) .src_subpass(vk::SUBPASS_EXTERNAL)
.dst_subpass(0) .dst_subpass(0)
.src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) .src_stage_mask(
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) 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()) .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 attachments = [color_attachment, depth_attachment];
let subpasses = [subpass]; let subpasses = [subpass];
let dependencies = [dependency]; let dependencies = [dependency];
@@ -409,16 +575,16 @@ fn create_pipeline_layout(
descriptor_set_layout: vk::DescriptorSetLayout, descriptor_set_layout: vk::DescriptorSetLayout,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> { ) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
let set_layouts = [descriptor_set_layout]; let set_layouts = [descriptor_set_layout];
let push_constant_ranges = [ // Material data occupies one 128-byte push-constant allocation shared by
vk::PushConstantRange::default() // both stages; the vertex stage also reads its specular pair from the
.stage_flags(vk::ShaderStageFlags::VERTEX) // 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) .offset(0)
.size(64), .size(32 * u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX))];
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
.offset(64)
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
let create_info = vk::PipelineLayoutCreateInfo::default() let create_info = vk::PipelineLayoutCreateInfo::default()
.set_layouts(&set_layouts) .set_layouts(&set_layouts)
.push_constant_ranges(&push_constant_ranges); .push_constant_ranges(&push_constant_ranges);
@@ -434,6 +600,9 @@ fn create_pipeline_layout(
fn create_texture_descriptor_bundle( fn create_texture_descriptor_bundle(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
textures: &[VulkanAllocatedImage], textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
frame_uniform_buffer: &VulkanAllocatedBuffer,
material_specular_buffer: &VulkanAllocatedBuffer,
) -> Result< ) -> Result<
( (
vk::DescriptorSetLayout, vk::DescriptorSetLayout,
@@ -443,12 +612,32 @@ fn create_texture_descriptor_bundle(
), ),
VulkanSmokeRendererError, VulkanSmokeRendererError,
> { > {
let binding = vk::DescriptorSetLayoutBinding::default() const TEXTURES_PER_MATERIAL: usize = 4;
.binding(0) 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_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1) .descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::FRAGMENT); .stage_flags(vk::ShaderStageFlags::FRAGMENT)
let bindings = [binding]; })
.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); let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
// SAFETY: The layout description is stack-owned and references no external memory. // SAFETY: The layout description is stack-owned and references no external memory.
let layout = unsafe { let layout = unsafe {
@@ -460,23 +649,40 @@ fn create_texture_descriptor_bundle(
context: "vkCreateDescriptorSetLayout", context: "vkCreateDescriptorSetLayout",
result, 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 { VulkanSmokeRendererError::InvariantViolation {
context: "static material texture count exceeds Vulkan descriptor limit", 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 { 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() let pool_size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(texture_count); .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() let pool_info = vk::DescriptorPoolCreateInfo::default()
.pool_sizes(&pool_sizes) .pool_sizes(&pool_sizes)
.max_sets(texture_count); .max_sets(material_count);
let pool = let pool =
// SAFETY: The pool description is stack-owned and reserves exactly one descriptor. // SAFETY: The pool description is stack-owned and reserves exactly one descriptor.
unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| { unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| {
@@ -487,7 +693,7 @@ fn create_texture_descriptor_bundle(
result, 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() let allocate_info = vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(pool) .descriptor_pool(pool)
.set_layouts(&layouts); .set_layouts(&layouts);
@@ -511,7 +717,9 @@ fn create_texture_descriptor_bundle(
.address_mode_u(vk::SamplerAddressMode::REPEAT) .address_mode_u(vk::SamplerAddressMode::REPEAT)
.address_mode_v(vk::SamplerAddressMode::REPEAT) .address_mode_v(vk::SamplerAddressMode::REPEAT)
.address_mode_w(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 = let sampler =
// SAFETY: The sampler create info is stack-owned and has no unsupported optional features. // 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| { unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| {
@@ -525,27 +733,51 @@ fn create_texture_descriptor_bundle(
result, result,
} }
})?; })?;
let image_infos = textures let image_infos = texture_slot_indices
.iter() .iter()
.map(|texture| { .map(|texture_index| {
let texture = &textures[*texture_index];
vk::DescriptorImageInfo::default() vk::DescriptorImageInfo::default()
.sampler(sampler) .sampler(sampler)
.image_view(texture.view) .image_view(texture.view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let writes = descriptor_sets let frame_info = vk::DescriptorBufferInfo::default()
.iter() .buffer(frame_uniform_buffer.buffer)
.copied() .offset(0)
.zip(image_infos.iter()) .range(VULKAN_FRAME_UNIFORM_BYTES as u64);
.map(|(descriptor_set, image_info)| { 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() vk::WriteDescriptorSet::default()
.dst_set(descriptor_set) .dst_set(descriptor_set)
.dst_binding(0) .dst_binding(u32::try_from(binding).unwrap_or(u32::MAX))
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(std::slice::from_ref(image_info)) .image_info(std::slice::from_ref(image_info)),
}) );
.collect::<Vec<_>>(); }
writes.push(
vk::WriteDescriptorSet::default()
.dst_set(descriptor_set)
.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. // SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition.
unsafe { device.device().update_descriptor_sets(&writes, &[]) }; unsafe { device.device().update_descriptor_sets(&writes, &[]) };
Ok((layout, pool, descriptor_sets, sampler)) Ok((layout, pool, descriptor_sets, sampler))
@@ -598,8 +830,9 @@ fn create_graphics_pipeline(
extent: (u32, u32), extent: (u32, u32),
state: LegacyPipelineState, state: LegacyPipelineState,
) -> Result<vk::Pipeline, VulkanSmokeRendererError> { ) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?; let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS, "vertex")?;
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) { let fragment_shader =
match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS, "fragment") {
Ok(module) => module, Ok(module) => module,
Err(error) => { Err(error) => {
// SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure. // SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure.
@@ -620,7 +853,7 @@ fn create_graphics_pipeline(
]; ];
let vertex_binding = vk::VertexInputBindingDescription::default() let vertex_binding = vk::VertexInputBindingDescription::default()
.binding(0) .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); .input_rate(vk::VertexInputRate::VERTEX);
let vertex_attributes = [ let vertex_attributes = [
vk::VertexInputAttributeDescription::default() vk::VertexInputAttributeDescription::default()
@@ -636,8 +869,23 @@ fn create_graphics_pipeline(
vk::VertexInputAttributeDescription::default() vk::VertexInputAttributeDescription::default()
.binding(0) .binding(0)
.location(2) .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)), .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_bindings = [vertex_binding];
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default() let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
@@ -685,6 +933,21 @@ fn create_graphics_pipeline(
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL) .depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
.depth_bounds_test_enable(false) .depth_bounds_test_enable(false)
.stencil_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() let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
.color_write_mask( .color_write_mask(
vk::ColorComponentFlags::R vk::ColorComponentFlags::R
@@ -692,12 +955,12 @@ fn create_graphics_pipeline(
| vk::ColorComponentFlags::B | vk::ColorComponentFlags::B
| vk::ColorComponentFlags::A, | vk::ColorComponentFlags::A,
) )
.blend_enable(state.blend == LegacyBlendMode::SourceAlpha) .blend_enable(blend_enable)
.src_color_blend_factor(vk::BlendFactor::SRC_ALPHA) .src_color_blend_factor(src_color_blend_factor)
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) .dst_color_blend_factor(dst_color_blend_factor)
.color_blend_op(vk::BlendOp::ADD) .color_blend_op(vk::BlendOp::ADD)
.src_alpha_blend_factor(vk::BlendFactor::ONE) .src_alpha_blend_factor(src_color_blend_factor)
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) .dst_alpha_blend_factor(dst_color_blend_factor)
.alpha_blend_op(vk::BlendOp::ADD); .alpha_blend_op(vk::BlendOp::ADD);
let color_blend_attachments = [color_blend_attachment]; let color_blend_attachments = [color_blend_attachment];
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default() let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
@@ -738,12 +1001,17 @@ fn create_graphics_pipeline(
fn create_shader_module( fn create_shader_module(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
words: &[u32], words: &[u32],
stage: &'static str,
) -> Result<vk::ShaderModule, VulkanSmokeRendererError> { ) -> Result<vk::ShaderModule, VulkanSmokeRendererError> {
let create_info = vk::ShaderModuleCreateInfo::default().code(words); 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. // 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| { unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation { VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateShaderModule", context: if stage == "vertex" {
"vkCreateShaderModule(vertex)"
} else {
"vkCreateShaderModule(fragment)"
},
result: error, result: error,
} }
}) })
@@ -821,7 +1089,9 @@ pub(super) fn destroy_swapchain_resources(
device device
.device() .device()
.destroy_render_pass(resources.render_pass, None); .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 { for image_view in resources.image_views {
device.device().destroy_image_view(image_view, None); 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().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, 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 { if let Some(render_pass) = partial.render_pass {
device.device().destroy_render_pass(render_pass, None); 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); destroy_depth_attachment(device, &depth_attachment);
} }
for image_view in partial.image_views { 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().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, 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);
}
} }
} }
@@ -386,13 +386,13 @@ fn static_surface_extension_name_is_decoded() {
} }
#[test] #[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"); let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
assert_eq!( assert_eq!(
plan.format, plan.format,
VulkanSurfaceFormat { VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(), format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.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!( assert_eq!(
plan.format, plan.format,
VulkanSurfaceFormat { VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(), format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
} }
); );
@@ -479,6 +479,66 @@ fn checked_in_shaders_have_valid_spirv_containers() {
assert_eq!(validate_triangle_shaders(), Ok(())); assert_eq!(validate_triangle_shaders(), Ok(()));
} }
#[test]
fn fragment_shader_dispatches_all_four_material_modes() {
let fragment = include_str!("../../shaders/triangle.frag");
assert!(fragment.contains("int mode = int(round(material.material_mode));"));
assert!(fragment.contains("mode == 0"));
assert!(fragment.contains("mode == 1"));
assert!(fragment.contains("mode == 2"));
assert!(fragment.contains("mode == 3"));
assert!(!fragment.contains("material_mode > 1.5"));
assert!(!fragment.contains("material_mode > 2.5"));
}
#[test]
fn native_specular_reference_uses_squared_power_and_total_overflow_curve() {
fn reference_power(cosine: f32, power: u8) -> f32 {
if power == 0 {
return 0.0;
}
let mut result = cosine;
for _ in 0..power.saturating_sub(1) {
result *= result;
}
result
}
fn reference_curve(value: f32) -> f32 {
if value <= 1.0 {
0.8 * value
} else if value <= 3.0 {
0.1 * value + 0.7
} else {
1.0
}
}
assert_eq!(reference_power(0.5, 0), 0.0);
assert!((reference_power(0.5, 1) - 0.5).abs() < 1e-6);
assert!((reference_power(0.5, 2) - 0.25).abs() < 1e-6);
assert!((reference_power(0.5, 3) - 0.0625).abs() < 1e-6);
let compressed_diffuse = 1.5;
let material_specular = 0.75;
assert!((reference_curve(material_specular + (compressed_diffuse - 1.0)) - 0.825).abs() < 1e-6);
let vertex = include_str!("../../shaders/triangle.vert");
assert!(vertex.contains("result *= result;"));
assert!(
vertex.contains("vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));")
);
assert!(vertex.contains("if (power <= 0.0 || cosine <= 0.0)"));
}
#[test]
fn shader_computes_radial_fog_per_vertex_and_interpolates_it() {
let vertex = include_str!("../../shaders/triangle.vert");
let fragment = include_str!("../../shaders/triangle.frag");
assert!(vertex.contains("out_fog_factor = native_fog_factor(in_position);"));
assert!(fragment.contains("layout(location = 4) in float in_fog_factor;"));
assert!(!fragment.contains("distance(in_world_position"));
assert!(!fragment.contains("float fog_factor()"));
}
#[test] #[test]
fn shader_container_rejects_invalid_spirv() { fn shader_container_rejects_invalid_spirv() {
assert_eq!( assert_eq!(
+16 -1
View File
@@ -458,9 +458,24 @@ fn select_surface_format(
.iter() .iter()
.copied() .copied()
.find(|format| { .find(|format| {
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.format == vk::Format::B8G8R8A8_SRGB.as_raw()
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw() && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
}) })
})
.or_else(|| formats.first().copied()) .or_else(|| formats.first().copied())
.ok_or(VulkanSwapchainError::MissingSurfaceFormat) .ok_or(VulkanSwapchainError::MissingSurfaceFormat)
} }
@@ -470,7 +485,7 @@ fn undefined_surface_format_override(
) -> Option<VulkanSurfaceFormat> { ) -> Option<VulkanSurfaceFormat> {
match formats { match formats {
[format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat { [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, color_space: format.color_space,
}), }),
_ => None, _ => None,
+7
View File
@@ -6,14 +6,21 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-animation = { path = "../../crates/fparkan-animation" }
fparkan-assets = { path = "../../crates/fparkan-assets" } fparkan-assets = { path = "../../crates/fparkan-assets" }
fparkan-fx = { path = "../../crates/fparkan-fx" }
fparkan-path = { path = "../../crates/fparkan-path" } fparkan-path = { path = "../../crates/fparkan-path" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-render = { path = "../../crates/fparkan-render" } fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } 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-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-terrain = { path = "../../crates/fparkan-terrain" } fparkan-terrain = { path = "../../crates/fparkan-terrain" }
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" } 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 = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] } winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+46 -4
View File
@@ -254,12 +254,16 @@ impl AnimKey24 {
]; ];
let time = AnimationTime(read_f32(bytes, 12)?); let time = AnimationTime(read_f32(bytes, 12)?);
validate_time(time)?; validate_time(time)?;
let raw_rotation = [ let disk_wxyz = [
f32::from(read_i16(bytes, 16)?) / 32767.0, f32::from(read_i16(bytes, 16)?) / 32767.0,
f32::from(read_i16(bytes, 18)?) / 32767.0, f32::from(read_i16(bytes, 18)?) / 32767.0,
f32::from(read_i16(bytes, 20)?) / 32767.0, f32::from(read_i16(bytes, 20)?) / 32767.0,
f32::from(read_i16(bytes, 22)?) / 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 { Ok(Self {
time, time,
pose: Pose { pose: Pose {
@@ -870,9 +874,47 @@ mod tests {
assert_eq!(key.time, AnimationTime(12.5)); assert_eq!(key.time, AnimationTime(12.5));
assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]); assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
assert!(key.pose.rotation[1] < 0.0); assert!(key.pose.rotation[0] < 0.0);
assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05); assert!((key.pose.rotation[0] + 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[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] #[test]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33 -7
View File
@@ -6,6 +6,11 @@
//! reference stub until opcode timing, gates, RNG, and command-body semantics //! reference stub until opcode timing, gates, RNG, and command-body semantics
//! are backed by runtime-captured evidence. //! are backed by runtime-captured evidence.
pub mod atmosphere;
pub mod environment;
pub mod shadow;
pub mod sky;
use fparkan_binary::{Cursor, DecodeError}; use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc; use std::sync::Arc;
@@ -85,8 +90,13 @@ pub struct FxCommand {
pub word: u32, pub word: u32,
/// Decoded opcode. /// Decoded opcode.
pub opcode: FxOpcode, pub opcode: FxOpcode,
/// Enabled bit. /// Native command flag stored in bit 8 of the command word.
pub enabled: bool, ///
/// 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. /// Command body after the word.
pub raw_body: Arc<[u8]>, pub raw_body: Arc<[u8]>,
/// Resource references discovered in known command layouts. /// Resource references discovered in known command layouts.
@@ -314,7 +324,7 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
commands.push(FxCommand { commands.push(FxCommand {
word, word,
opcode, opcode,
enabled: ((word >> 8) & 1) != 0, native_flag: ((word >> 8) & 1) != 0,
raw_body, raw_body,
resource_refs, resource_refs,
}); });
@@ -369,9 +379,6 @@ pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
return Ok(()); return Ok(());
} }
for (index, command) in state.document.commands.iter().enumerate() { for (index, command) in state.document.commands.iter().enumerate() {
if !command.enabled {
continue;
}
let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?; let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
if command.opcode == FxOpcode::Op2 { if command.opcode == FxOpcode::Op2 {
out.push(FxEmission::Sound(FxSoundEvent { command_index })); out.push(FxEmission::Sound(FxSoundEvent { command_index }));
@@ -605,7 +612,7 @@ mod tests {
assert_eq!(document.header().command_count, 2); assert_eq!(document.header().command_count, 2);
assert_eq!(document.commands()[0].opcode, FxOpcode::Op2); assert_eq!(document.commands()[0].opcode, FxOpcode::Op2);
assert!(document.commands()[0].enabled); assert!(document.commands()[0].native_flag);
assert_eq!( assert_eq!(
document.commands()[0].resource_refs[0].archive_name(), document.commands()[0].resource_refs[0].archive_name(),
b"sounds.lib" b"sounds.lib"
@@ -640,6 +647,25 @@ mod tests {
assert!(document.commands()[0].raw_body.is_empty()); assert!(document.commands()[0].raw_body.is_empty());
} }
#[test]
fn zero_native_flags_still_emit_every_command() {
let mut bytes = header(3);
bytes.extend_from_slice(&command(0x0003, 200));
bytes.extend_from_slice(&command(0x0001, 224));
bytes.extend_from_slice(&command(0x0002, 148));
let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
assert!(document
.commands()
.iter()
.all(|command| !command.native_flag));
let state = create_instance(document, FxSeed(9), Transform::default()).expect("state");
assert_eq!(
canonical_emission_capture(&state).expect("capture"),
b"P,0, Op3\nP,1, Op1\nS,2\n"
);
}
#[test] #[test]
fn rejects_unknown_opcode_at_command_index() { fn rejects_unknown_opcode_at_command_index() {
let mut bytes = header(1); let mut bytes = header(1);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+596 -64
View File
@@ -18,6 +18,9 @@ pub const STREAM_POSITIONS: u32 = 3;
pub const STREAM_NORMALS: u32 = 4; pub const STREAM_NORMALS: u32 = 4;
/// Texture coordinate stream. /// Texture coordinate stream.
pub const STREAM_UV0: u32 = 5; 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. /// Triangle index stream.
pub const STREAM_INDICES: u32 = 6; pub const STREAM_INDICES: u32 = 6;
/// Animation key stream. /// Animation key stream.
@@ -81,14 +84,20 @@ pub struct ModelAsset {
pub node_count: usize, pub node_count: usize,
/// Raw node table. /// Raw node table.
pub nodes_raw: Vec<u8>, 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. /// Slot table.
pub slots: Vec<Slot>, pub slots: Vec<Slot>,
/// Vertex positions. /// Vertex positions.
pub positions: Vec<[f32; 3]>, pub positions: Vec<[f32; 3]>,
/// Optional normals. /// Optional normals.
pub normals: Option<Vec<[i8; 4]>>, pub normals: Option<Vec<[i8; 4]>>,
/// Optional texture coordinates. /// Optional primary packed texture coordinates from type 5.
pub uv0: Option<Vec<[i16; 2]>>, pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
/// Triangle indices. /// Triangle indices.
pub indices: Vec<u16>, pub indices: Vec<u16>,
/// Draw batches. /// Draw batches.
@@ -167,18 +176,24 @@ pub struct Slot {
pub struct Batch { pub struct Batch {
/// Batch flags. /// Batch flags.
pub batch_flags: u16, pub batch_flags: u16,
/// Material index. /// High material selector/slot field at byte offset `+0x02`.
pub material_index: u16, ///
/// Opaque field. /// The ordinary WEAR material selector is the byte at `+0x04`. This
pub opaque4: u16, /// field is retained separately because the native path can override it
/// Opaque field. /// with a forced material index.
pub opaque6: u16, 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. /// Index count.
pub index_count: u16, pub index_count: u16,
/// First index offset. /// First index offset.
pub index_start: u32, pub index_start: u32,
/// Opaque field. /// Vertex count at byte offset `+0x0E`.
pub opaque14: u16, pub vertex_count: u16,
/// Base vertex. /// Base vertex.
pub base_vertex: u32, pub base_vertex: u32,
} }
@@ -194,8 +209,10 @@ pub struct VertexStreams {
pub positions: Vec<[f32; 3]>, pub positions: Vec<[f32; 3]>,
/// Optional normals. /// Optional normals.
pub normals: Option<Vec<[i8; 4]>>, pub normals: Option<Vec<[i8; 4]>>,
/// Optional texture coordinates. /// Optional primary packed texture coordinates from type 5.
pub uv0: Option<Vec<[i16; 2]>>, pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
} }
/// Preserved non-core stream. /// Preserved non-core stream.
@@ -209,11 +226,11 @@ pub struct PreservedStream {
pub bytes: std::sync::Arc<[u8]>, pub bytes: std::sync::Arc<[u8]>,
} }
/// LOD id. /// Native state id kept under the historical `Lod` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Lod(pub u8); pub struct Lod(pub u8);
/// Group id. /// Native LOD id kept under the historical `Group` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Group(pub u8); pub struct Group(pub u8);
@@ -377,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> {
Ok(model) 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] #[must_use]
pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> { 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 { 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 /// 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 /// 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 /// 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] #[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> { pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?; let metadata = node38_metadata(model, node)?;
@@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
.keys .keys
.get(usize::from(metadata.fallback_key)) .get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose) .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. /// 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() evaluate_hierarchy(&parents, &poses).ok()
} }
/// Evaluates the portable-reference pose hierarchy of a standard `Node38` /// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical
/// model at one explicitly supplied logical frame. /// 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 /// 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 /// 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. /// representation or the map cannot be safely resolved.
#[must_use] #[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> { 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 { if model.node_stride != 38 || model.node_count == 0 {
return None; 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_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?; let _fallback = animation.keys.get(fallback_index)?;
let key_index = let sample = selector_for_node(index);
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count { 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 fallback_index
} else { } else {
let mapped_index = let mapped_index = usize::from(
usize::from(*animation.frame_map.get( *animation.frame_map.get(
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?, usize::from(metadata.anim_map_start)
)?); .checked_add(usize::try_from(map_frame).ok()?)?,
)?,
);
if mapped_index < fallback_index { if mapped_index < fallback_index {
mapped_index mapped_index
} else { } else {
fallback_index fallback_index
} }
}
} else {
fallback_index
}; };
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?; 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); parents.push(parent);
poses.push(pose); poses.push(pose);
} }
@@ -517,7 +619,7 @@ fn sample_node38_key_pair(
keys: &[AnimKey24], keys: &[AnimKey24],
key_index: usize, key_index: usize,
fallback_index: usize, fallback_index: usize,
frame: u16, sample_time: AnimationTime,
) -> Option<Pose> { ) -> Option<Pose> {
let key = *keys.get(key_index)?; let key = *keys.get(key_index)?;
if key_index == fallback_index { if key_index == fallback_index {
@@ -541,7 +643,7 @@ fn sample_node38_key_pair(
], ],
) )
.ok()?; .ok()?;
track.sample(AnimationTime(f32::from(frame))).ok() track.sample(sample_time).ok()
} }
/// Returns draw batches for a validated slot. /// 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 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 slots = parse_slots(&slots_stream.bytes)?;
let positions = parse_positions(&positions_stream.bytes)?; let positions = parse_positions(&positions_stream.bytes)?;
let indices = parse_u16_array(&indices_stream.bytes, "Res6")?; 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")) .map(|raw| parse_i8x4_array(&raw.bytes, "Res4"))
.transpose()?; .transpose()?;
let uv0 = read_optional_stream(document, STREAM_UV0)? 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()?; .transpose()?;
let node_names = read_optional_stream(document, STREAM_NAMES)? let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count)) .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_stride,
node_count, node_count,
nodes_raw: nodes_stream.bytes, nodes_raw: nodes_stream.bytes,
bounding_sphere_center,
bounding_sphere_radius,
slots, slots,
positions, positions,
normals, normals,
uv0, uv0,
uv1,
indices, indices,
batches, batches,
node_names, node_names,
@@ -822,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
Ok(slots) 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> { fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> {
if !data.len().is_multiple_of(12) { if !data.len().is_multiple_of(12) {
return Err(invalid_resource_size("Res3", data.len(), 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) { for offset in (0..data.len()).step_by(20) {
out.push(Batch { out.push(Batch {
batch_flags: read_u16_required(data, offset)?, batch_flags: read_u16_required(data, offset)?,
material_index: read_u16_required(data, offset + 2)?, material_index_hi: read_u16_required(data, offset + 2)?,
opaque4: read_u16_required(data, offset + 4)?, material_index: *data
opaque6: read_u16_required(data, offset + 6)?, .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_count: read_u16_required(data, offset + 8)?,
index_start: read_u32(data, offset + 10)?, 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)?, 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) 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) { if !data.len().is_multiple_of(4) {
return Err(invalid_resource_size(label, data.len(), 4)); return Err(invalid_resource_size(label, data.len(), 4));
} }
let mut out = Vec::with_capacity(data.len() / 4); let mut out = Vec::with_capacity(data.len() / 4);
for offset in (0..data.len()).step_by(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) 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> { fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> {
let mut out = Vec::with_capacity(node_count); let mut out = Vec::with_capacity(node_count);
let mut offset = 0usize; 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)) 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> { fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
let byte = bytes let byte = bytes
.get(offset) .get(offset)
@@ -1309,18 +1469,21 @@ mod tests {
let mut nodes = root; let mut nodes = root;
nodes.extend(child); nodes.extend(child);
let mut keys = Vec::new(); 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), (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, x);
push_f32(&mut keys, y); push_f32(&mut keys, y);
push_f32(&mut keys, z); push_f32(&mut keys, z);
push_f32(&mut keys, 0.0); 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, 0); push_u16(&mut keys, 0);
push_u16(&mut keys, qz.cast_unsigned()); push_u16(&mut keys, qz.cast_unsigned());
push_u16(&mut keys, qw.cast_unsigned());
} }
let document = decode_nested(&build_nres(&[ let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes), 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"); validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy"); 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[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] #[test]
@@ -1349,10 +1583,13 @@ mod tests {
node_stride: 38, node_stride: 38,
node_count: 1, node_count: 1,
nodes_raw: node, nodes_raw: node,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(), slots: Vec::new(),
positions: Vec::new(), positions: Vec::new(),
normals: None, normals: None,
uv0: None, uv0: None,
uv1: None,
indices: Vec::new(), indices: Vec::new(),
batches: Vec::new(), batches: Vec::new(),
node_names: None, node_names: None,
@@ -1394,6 +1631,105 @@ mod tests {
); );
} }
#[test]
fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[4..6].copy_from_slice(&0_u16.to_le_bytes());
root[6..8].copy_from_slice(&4_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[4..6].copy_from_slice(&2_u16.to_le_bytes());
child[6..8].copy_from_slice(&5_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let model = ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [4.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [8.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(1.0),
pose: Pose {
translation: [20.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [30.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose {
translation: [100.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose {
translation: [300.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
],
frame_map: vec![0, 1, 0, 2],
frame_count: 4,
}),
};
let hierarchy = node38_sampled_hierarchy_at_times(
&model,
&[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))],
)
.expect("per-node fractional hierarchy");
// The root's tie selects map frame zero and interpolates to x=6. The
// child independently selects frame one and samples its own 1.55 time;
// its global pose includes the root translation.
assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON);
assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001);
let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None])
.expect("per-node fallback hierarchy");
assert_eq!(fallback.poses[1].translation[0], 306.0);
assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none());
for invalid in [-1.0, f32::NAN, f32::INFINITY] {
assert!(node38_sampled_hierarchy_at_times(
&model,
&[Some(AnimationTime(invalid)), None]
)
.is_none());
}
}
#[test] #[test]
fn type2_header_and_slot_tail_framing_are_exact() { fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[ let too_small = decode_nested(&build_nres(&[
@@ -1420,6 +1756,44 @@ mod tests {
assert!(matches!(err, MshError::InvalidGeometry(_))); assert!(matches!(err, MshError::InvalidGeometry(_)));
} }
#[test]
fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() {
let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &valid_slots),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
]))
.expect("nested");
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]);
assert_eq!(model.bounding_sphere_radius, 4.5);
for (offset, value) in [
(0x60, f32::NAN),
(0x68, f32::INFINITY),
(0x6C, f32::NAN),
(0x6C, -1.0),
] {
let mut slots = slots_payload(&[]);
slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
]))
.expect("nested");
assert!(matches!(
validate_msh(&decode_msh(&document).expect("msh")),
Err(MshError::InvalidGeometry(_))
));
}
}
#[test] #[test]
fn slot_batch_range_out_of_bounds_is_error() { 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 slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
@@ -1448,6 +1822,7 @@ mod tests {
(STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]), (STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]),
(STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]), (STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]),
(STREAM_UV0, b"Res5".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]), (STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]),
] { ] {
let slots = slots_payload(&[]); let slots = slots_payload(&[]);
@@ -1471,6 +1846,127 @@ mod tests {
} }
} }
#[test]
fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]);
let mut uv1 = Vec::new();
for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] {
push_u16(&mut uv1, pair[0]);
push_u16(&mut uv1, pair[1]);
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1),
]))
.expect("nested");
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.positions.len(), 4);
assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]]));
}
#[test]
fn type18_secondary_uv_rejects_untrusted_count_or_stride() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]);
for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] {
let mut uv1 = Vec::new();
push_u16(&mut uv1, 2);
push_u16(&mut uv1, 5);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1),
]))
.expect("nested");
let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid");
let message = err.to_string();
assert!(message.contains(expected), "{message}");
}
}
#[test]
fn installed_gog_secondary_uv_fixture_is_aligned_and_real() {
let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else {
return;
};
let root = PathBuf::from(root);
assert!(
root.is_dir(),
"FPARKAN_GAME_ROOT is missing: {}",
root.display()
);
let mut models_with_uv1 = 0usize;
let mut fixture_found = false;
for path in files_under(&root) {
if !path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("rlb"))
{
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(archive) =
fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
else {
continue;
};
for entry in archive
.entries()
.iter()
.filter(|entry| has_msh_extension(entry.name_bytes()))
{
let payload = archive.payload(entry.id()).expect("payload");
let nested = fparkan_nres::decode(
Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let msh = decode_msh(&nested)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let model = validate_msh(&msh)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let Some(uv1) = model.uv1.as_ref() else {
continue;
};
models_with_uv1 += 1;
assert_eq!(
uv1.len(),
model.positions.len(),
"{path:?} {:?}",
entry.name_bytes()
);
let descriptor = msh
.streams()
.iter()
.find(|stream| stream.type_id == STREAM_UV1)
.expect("Res18 descriptor");
assert_eq!(descriptor.attributes.attr1 as usize, uv1.len());
assert_eq!(descriptor.attributes.attr3, 4);
if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") {
assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]);
fixture_found = true;
}
}
}
assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH");
assert!(
fixture_found,
"FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture"
);
}
#[test] #[test]
fn batch20_uses_unaligned_field_offsets() { fn batch20_uses_unaligned_field_offsets() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]]); let positions = positions_payload(&[[0.0, 0.0, 0.0]]);
@@ -1488,12 +1984,13 @@ mod tests {
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.batches[0].batch_flags, 0x1100); assert_eq!(model.batches[0].batch_flags, 0x1100);
assert_eq!(model.batches[0].material_index, 0x2200); assert_eq!(model.batches[0].material_index_hi, 0x2200);
assert_eq!(model.batches[0].opaque4, 0x3300); assert_eq!(model.batches[0].material_index, 0x00);
assert_eq!(model.batches[0].opaque6, 0x4400); 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_count, 1);
assert_eq!(model.batches[0].index_start, 0); 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); assert_eq!(model.batches[0].base_vertex, 0);
} }
@@ -1587,18 +2084,26 @@ mod tests {
#[test] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn licensed_corpus_msh_assets_validate() { fn licensed_corpus_part1_msh_assets_validate() {
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] { 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 root = corpus_root(corpus);
let mut count = 0usize; let mut count = 0usize;
for path in files_under(&root) { for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else { let Ok(bytes) = std::fs::read(&path) else {
continue; continue;
}; };
let Ok(archive) = fparkan_nres::decode( let Ok(archive) =
Arc::from(bytes.into_boxed_slice()), fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
ReadProfile::Compatible, else {
) else {
continue; continue;
}; };
for entry in archive for entry in archive
@@ -1611,9 +2116,7 @@ mod tests {
Arc::from(payload.to_vec().into_boxed_slice()), Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible, ReadProfile::Compatible,
) )
.unwrap_or_else(|err| { .unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()));
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
let msh = decode_msh(&nested).unwrap_or_else(|err| { let msh = decode_msh(&nested).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
}); });
@@ -1625,7 +2128,6 @@ mod tests {
} }
assert_eq!(count, expected, "{corpus} MSH count"); assert_eq!(count, expected, "{corpus} MSH count");
} }
}
#[test] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
@@ -1932,6 +2434,23 @@ mod tests {
fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> { fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> {
TestEntry { TestEntry {
type_id, 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, attr3,
name, name,
payload, payload,
@@ -1940,6 +2459,7 @@ mod tests {
struct TestEntry<'a> { struct TestEntry<'a> {
type_id: u32, type_id: u32,
attr1: u32,
attr3: u32, attr3: u32,
name: &'a [u8], name: &'a [u8],
payload: &'a [u8], payload: &'a [u8],
@@ -1958,7 +2478,7 @@ mod tests {
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
for (idx, entry) in entries.iter().enumerate() { for (idx, entry) in entries.iter().enumerate() {
push_u32(&mut out, entry.type_id); 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, 0);
push_u32( push_u32(
&mut out, &mut out,
@@ -2000,7 +2520,19 @@ mod tests {
} }
fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> { 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]; 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 { for record in records {
assert_eq!(record.len(), 68); assert_eq!(record.len(), 68);
out.extend_from_slice(record); out.extend_from_slice(record);
+401 -16
View File
@@ -15,6 +15,9 @@ const UNIT_DAT_MIN_SIZE: usize = 0x48;
const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1; const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32; 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. /// Prototype key.
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct PrototypeKey(pub ResourceName); pub struct PrototypeKey(pub ResourceName);
@@ -61,10 +64,21 @@ pub struct UnitComponentRecord {
pub parent_or_link: i32, pub parent_or_link: i32,
/// Description raw bytes. /// Description raw bytes.
pub description_raw: [u8; 32], pub description_raw: [u8; 32],
/// Opaque tail. /// Opaque record word at offset `0x68`.
pub tail0: u32, pub tail0: u32,
/// Opaque tail. /// Number of immediate children in the preorder unit tree.
pub tail1: u32, 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. /// Prototype geometry.
@@ -85,8 +99,18 @@ pub struct EffectivePrototype {
pub geometry: PrototypeGeometry, pub geometry: PrototypeGeometry,
/// Resolution source. /// Resolution source.
pub source: PrototypeSource, 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. /// Resource dependencies discovered while resolving this prototype.
pub dependencies: Vec<ResourceKey>, 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. /// Prototype resolution source.
@@ -401,6 +425,8 @@ pub enum PrototypeError {
InvalidSize, InvalidSize,
/// Invalid unit DAT magic. /// Invalid unit DAT magic.
InvalidUnitDatMagic(u32), InvalidUnitDatMagic(u32),
/// Unit DAT child counts do not describe one complete preorder tree.
InvalidUnitHierarchy(String),
/// Invalid path. /// Invalid path.
InvalidPath(String), InvalidPath(String),
/// VFS error. /// VFS error.
@@ -435,6 +461,9 @@ impl std::fmt::Display for PrototypeError {
Self::InvalidUnitDatMagic(magic) => { Self::InvalidUnitDatMagic(magic) => {
write!(f, "invalid unit DAT magic: {magic:#010X}") 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::InvalidPath(value) => write!(f, "invalid path: {value}"),
Self::Vfs(source) => write!(f, "vfs error: {source}"), Self::Vfs(source) => write!(f, "vfs error: {source}"),
Self::Resource(source) => write!(f, "resource 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)> { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self { match self {
Self::Decode(source) => Some(source), 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::Vfs(source) => Some(source),
Self::Resource(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()?; let parent_or_link = cursor.read_i32_le()?;
description_raw.copy_from_slice(cursor.read_exact(32)?); description_raw.copy_from_slice(cursor.read_exact(32)?);
let tail0 = cursor.read_u32_le()?; let tail0 = cursor.read_u32_le()?;
let tail1 = cursor.read_u32_le()?; let immediate_child_count = cursor.read_u32_le()?;
records.push(UnitComponentRecord { records.push(UnitComponentRecord {
archive_raw, archive_raw,
resource_raw, resource_raw,
@@ -520,7 +552,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
parent_or_link, parent_or_link,
description_raw, description_raw,
tail0, tail0,
tail1, immediate_child_count,
}); });
} }
cursor.require_eof()?; 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. /// Decodes a mission unit DAT binding.
/// ///
/// # Errors /// # Errors
@@ -1029,7 +1141,7 @@ fn resolve_archive_model(
let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else { let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else {
return Ok(None); 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( fn resolve_objects_registry_model(
@@ -1037,17 +1149,33 @@ fn resolve_objects_registry_model(
registry_archive: &NormalizedPath, registry_archive: &NormalizedPath,
object_key: &ResourceName, object_key: &ResourceName,
) -> Result<Option<EffectivePrototype>, PrototypeError> { ) -> 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) = let Some(refs) =
collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)? collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)?
else { else {
return Ok(None); return Ok(None);
}; };
let control_resource = resolve_explicit_control_resource(object_key, &refs)?;
let mut missing_mesh_refs = Vec::new(); let mut missing_mesh_refs = Vec::new();
for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) { for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) {
if let Some(prototype) = if let Some(prototype) = resolve_object_ref_model(
resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))? repository,
{ object_key,
item,
cstr_bytes(&item.resource_raw),
source_type,
control_resource.clone(),
)? {
return Ok(Some(prototype)); return Ok(Some(prototype));
} }
missing_mesh_refs.push(describe_object_ref(item)); missing_mesh_refs.push(describe_object_ref(item));
@@ -1064,7 +1192,9 @@ fn resolve_objects_registry_model(
key: PrototypeKey(object_key.clone()), key: PrototypeKey(object_key.clone()),
geometry: PrototypeGeometry::NonGeometric, geometry: PrototypeGeometry::NonGeometric,
source: PrototypeSource::ObjectsRegistry, source: PrototypeSource::ObjectsRegistry,
source_type,
dependencies: Vec::new(), dependencies: Vec::new(),
control_resource,
})) }))
} }
@@ -1130,6 +1260,8 @@ fn resolve_object_ref_model(
requested: &ResourceName, requested: &ResourceName,
item: &ObjectRefRecord, item: &ObjectRefRecord,
model_name: &[u8], model_name: &[u8],
source_type: Option<u32>,
control_resource: Option<ResourceKey>,
) -> Result<Option<EffectivePrototype>, PrototypeError> { ) -> Result<Option<EffectivePrototype>, PrototypeError> {
let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; 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()))? let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))?
@@ -1140,6 +1272,8 @@ fn resolve_object_ref_model(
requested.clone(), requested.clone(),
mesh, mesh,
PrototypeSource::ObjectsRegistry, 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") 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 { fn describe_object_ref(item: &ObjectRefRecord) -> String {
format!( format!(
"{}:{}", "{}:{}",
@@ -1195,12 +1384,16 @@ fn effective(
requested: ResourceName, requested: ResourceName,
mesh: ResourceKey, mesh: ResourceKey,
source: PrototypeSource, source: PrototypeSource,
source_type: Option<u32>,
control_resource: Option<ResourceKey>,
) -> EffectivePrototype { ) -> EffectivePrototype {
EffectivePrototype { EffectivePrototype {
key: PrototypeKey(requested), key: PrototypeKey(requested),
geometry: PrototypeGeometry::Mesh(mesh.clone()), geometry: PrototypeGeometry::Mesh(mesh.clone()),
source, source,
source_type,
dependencies: vec![mesh], dependencies: vec![mesh],
control_resource,
} }
} }
@@ -1380,7 +1573,7 @@ mod tests {
assert_eq!(record.parent_or_link, -7); assert_eq!(record.parent_or_link, -7);
assert_eq!(&record.description_raw[..description.len()], description); assert_eq!(&record.description_raw[..description.len()], description);
assert_eq!(record.tail0, 0x1122_3344); assert_eq!(record.tail0, 0x1122_3344);
assert_eq!(record.tail1, 0x5566_7788); assert_eq!(record.immediate_child_count, 0x5566_7788);
} }
#[test] #[test]
@@ -1406,6 +1599,88 @@ mod tests {
assert_eq!(unit.records[0].parent_or_link, 12); assert_eq!(unit.records[0].parent_or_link, 12);
} }
#[test]
fn unit_component_tree_follows_preorder_child_counts_and_keeps_mount_links() {
let mut bytes = build_unit_dat(&[
(b"objects.rlb".as_slice(), b"root".as_slice()),
(b"objects.rlb".as_slice(), b"child".as_slice()),
(b"objects.rlb".as_slice(), b"grandchild".as_slice()),
]);
bytes[8 + 68..8 + 72].copy_from_slice(&(-1_i32).to_le_bytes());
bytes[8 + 108..8 + 112].copy_from_slice(&1_u32.to_le_bytes());
bytes[8 + 112 + 68..8 + 112 + 72].copy_from_slice(&7_i32.to_le_bytes());
bytes[8 + 112 + 108..8 + 112 + 112].copy_from_slice(&1_u32.to_le_bytes());
bytes[8 + 224 + 68..8 + 224 + 72].copy_from_slice(&3_i32.to_le_bytes());
let unit = decode_unit_dat(&bytes).expect("unit");
let tree = unit_component_tree(&unit.records).expect("preorder tree");
assert_eq!(tree.len(), 3);
assert_eq!(tree[0].record_index, 0);
assert_eq!(tree[0].parent_index, None);
assert_eq!(tree[0].children, vec![1]);
assert_eq!(tree[1].parent_index, Some(0));
assert_eq!(tree[1].children, vec![2]);
assert_eq!(tree[2].parent_index, Some(1));
assert_eq!(tree[2].children, Vec::<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] #[test]
fn resolves_synthetic_objects_registry_model() { fn resolves_synthetic_objects_registry_model() {
let mut vfs = MemoryVfs::default(); let mut vfs = MemoryVfs::default();
@@ -1436,6 +1711,7 @@ mod tests {
.expect("prototype"); .expect("prototype");
assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry); assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry);
assert!(resolved.control_resource.is_none());
let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
panic!("expected mesh"); panic!("expected mesh");
}; };
@@ -1855,10 +2131,10 @@ mod tests {
build_nres(&[ build_nres(&[
( (
b"parent_proto".as_slice(), b"parent_proto".as_slice(),
build_object_refs(&[( build_object_refs(&[
b"static.rlb".as_slice(), (b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()),
b"parent_proto.msh".as_slice(), (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
)]) ])
.as_slice(), .as_slice(),
), ),
( (
@@ -1866,6 +2142,7 @@ mod tests {
build_object_refs(&[ build_object_refs(&[
(b"objects.rlb".as_slice(), b"parent_proto".as_slice()), (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"child_proto.bas".as_slice()),
(b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
]) ])
.as_slice(), .as_slice(),
), ),
@@ -1881,7 +2158,13 @@ mod tests {
); );
vfs.insert( vfs.insert(
fortif_path, 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 vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone()); let repo = CachedResourceRepository::new(vfs.clone());
@@ -1895,6 +2178,108 @@ mod tests {
}; };
assert_eq!(mesh.archive.as_str(), "static.rlb"); assert_eq!(mesh.archive.as_str(), "static.rlb");
assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh")); 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] #[test]
+85 -3
View File
@@ -319,7 +319,7 @@ impl LegacyIron3dEulerTransform {
/// Fixed-function blend behaviour represented without a graphics API type. /// 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)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyBlendMode { pub enum LegacyBlendMode {
/// Do not blend the fragment with the existing colour. /// Do not blend the fragment with the existing colour.
@@ -327,6 +327,14 @@ pub enum LegacyBlendMode {
Opaque, Opaque,
/// Blend using source alpha. /// Blend using source alpha.
SourceAlpha, 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. /// Depth-buffer behaviour represented without a graphics API type.
@@ -369,10 +377,36 @@ pub struct LegacyPipelineState {
pub alpha_test: bool, 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. /// Canonical, backend-neutral key for a graphics-pipeline variant.
/// ///
/// The value is explicitly packed rather than hashed, so captures and caches /// 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)] #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PipelineKey(u8); pub struct PipelineKey(u8);
@@ -389,6 +423,9 @@ impl From<LegacyPipelineState> for PipelineKey {
let blend = match state.blend { let blend = match state.blend {
LegacyBlendMode::Opaque => 0, LegacyBlendMode::Opaque => 0,
LegacyBlendMode::SourceAlpha => 1, LegacyBlendMode::SourceAlpha => 1,
LegacyBlendMode::Additive => 2,
LegacyBlendMode::ZeroSourceColor => 3,
LegacyBlendMode::DestColorSourceColor => 4,
}; };
let depth = match state.depth { let depth = match state.depth {
LegacyDepthMode::Disabled => 0, LegacyDepthMode::Disabled => 0,
@@ -400,7 +437,7 @@ impl From<LegacyPipelineState> for PipelineKey {
LegacyCullMode::BackFace => 1, LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2, 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 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));
}
}
} }
+5 -1
View File
@@ -1761,7 +1761,7 @@ mod tests {
#[test] #[test]
#[ignore = "requires licensed corpus"] #[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")); let part1 = load_all(&licensed_root("IS"));
assert_eq!(part1.missions, 29); assert_eq!(part1.missions, 29);
assert_eq!(part1.paths, 34); assert_eq!(part1.paths, 34);
@@ -1781,7 +1781,11 @@ mod tests {
assert_eq!(part1.material_slots, part1.material_resolved); assert_eq!(part1.material_slots, part1.material_resolved);
assert_eq!(part1.texture_requests, part1.texture_resolved); assert_eq!(part1.texture_requests, part1.texture_resolved);
assert_eq!(part1.lightmap_requests, part1.lightmap_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")); let part2 = load_all(&licensed_root("IS2"));
assert_eq!(part2.missions, 31); assert_eq!(part2.missions, 31);
assert_eq!(part2.paths, 61); assert_eq!(part2.paths, 61);
+108 -41
View File
@@ -43,16 +43,18 @@ pub struct CompactSurfaceMask(pub u16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8); 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 /// Both selectors address the map-local `Land1.wea` table. The low byte is
/// no-selection sentinel. The low byte selects from `Land2.wea`. /// 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers { pub struct TerrainMaterialLayers {
/// Optional high-byte selector for `Land1.wea`. /// Low-byte base selector for `Land1.wea`.
pub land1_selector: Option<u8>, pub base_selector: u8,
/// Low-byte selector for `Land2.wea`. /// Optional high-byte overlay selector for `Land1.wea`.
pub land2_selector: u8, pub overlay_selector: Option<u8>,
} }
/// One `World3D` material-manager lookup key. /// One `World3D` material-manager lookup key.
@@ -79,31 +81,56 @@ impl TerrainMaterialLayers {
/// Constructs the decoded pair from its on-disk packed tag. /// Constructs the decoded pair from its on-disk packed tag.
#[must_use] #[must_use]
pub const fn from_packed_tag(material_tag: u16) -> Self { 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 { Self {
land1_selector: if land1 == u8::MAX { None } else { Some(land1) }, base_selector,
land2_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] #[must_use]
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> { pub fn base_selection(self) -> TerrainMaterialSelection {
self.land1_selector 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 { .map(|material_index| TerrainMaterialSelection {
table_index: 0, table_index: 0,
material_index: u16::from(material_index), 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] #[must_use]
pub fn land2_selection(self) -> TerrainMaterialSelection { pub fn detail_base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection { TerrainMaterialSelection {
table_index: 1, 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. /// Terrain face with 28-byte source layout.
@@ -231,7 +258,7 @@ pub struct LandMeshDocument {
/// Packed normals from type 4. /// Packed normals from type 4.
pub normals: Vec<[i8; 4]>, pub normals: Vec<[i8; 4]>,
/// Packed UV from type 5. /// Packed UV from type 5.
pub uv0: Vec<[i16; 2]>, pub uv0: Vec<[u16; 2]>,
/// Type 11 accelerator words. /// Type 11 accelerator words.
pub accelerator: Vec<[u8; 4]>, pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words. /// Type 14 auxiliary words.
@@ -631,7 +658,7 @@ pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainF
validate_slots(nres, slots)?; validate_slots(nres, slots)?;
let positions = parse_positions(nres, positions)?; let positions = parse_positions(nres, positions)?;
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?; 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 accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?; let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?; 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 payload = nres.payload(entry.id())?;
let areal_count = let areal_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?; 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 cursor = Cursor::new(payload);
let mut areals = Vec::with_capacity(areal_count); let mut areals = Vec::with_capacity(areal_count);
for area_index in 0..areal_count { for area_index in 0..areal_count {
@@ -1072,11 +1104,11 @@ fn parse_i8x4_stream(
.collect()) .collect())
} }
fn parse_i16x2_stream( fn parse_u16x2_stream(
nres: &NresDocument, nres: &NresDocument,
type_id: u32, type_id: u32,
payload: &[u8], payload: &[u8],
) -> Result<Vec<[i16; 2]>, TerrainFormatError> { ) -> Result<Vec<[u16; 2]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) { if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize { return Err(TerrainFormatError::InvalidSize {
type_id, type_id,
@@ -1088,7 +1120,7 @@ fn parse_i16x2_stream(
validate_stream(nres, type_id, 4, count)?; validate_stream(nres, type_id, 4, count)?;
let mut out = Vec::with_capacity(count); let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(4) { 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) Ok(out)
} }
@@ -1311,6 +1343,7 @@ fn parse_areal_grid(
if cell_count == 0 { if cell_count == 0 {
return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y }); return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
} }
checked_count_bytes(u64::from(cell_count), 2, cursor.remaining() as u64)?;
let cell_count_usize = let cell_count_usize =
usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?; usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut cells = Vec::with_capacity(cell_count_usize); 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]])) 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> { fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
let raw = bytes let raw = bytes
.get(offset..offset + 4) .get(offset..offset + 4)
@@ -1574,7 +1600,7 @@ mod tests {
} }
#[test] #[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]); let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes()); raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1583,24 +1609,40 @@ mod tests {
assert_eq!( assert_eq!(
document.faces[0].material_layers(), document.faces[0].material_layers(),
TerrainMaterialLayers { TerrainMaterialLayers {
land1_selector: Some(1), base_selector: 2,
land2_selector: 2, overlay_selector: Some(1),
} }
); );
assert_eq!( assert_eq!(
document.faces[0].material_layers().land1_selection(), document.faces[0].material_layers().base_selection(),
Some(TerrainMaterialSelection { TerrainMaterialSelection {
table_index: 0, table_index: 0,
material_index: 1, material_index: 2,
}) }
); );
assert_eq!( assert_eq!(
document.faces[0] document.faces[0]
.material_layers() .material_layers()
.land2_selection() .overlay_selection()
.expect("overlay")
.phase_key(),
0x0000_0001
);
assert_eq!(
document.faces[0]
.material_layers()
.detail_base_selection()
.phase_key(), .phase_key(),
0x0001_0002 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()); raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1608,18 +1650,28 @@ mod tests {
assert_eq!( assert_eq!(
document.faces[0].material_layers(), document.faces[0].material_layers(),
TerrainMaterialLayers { TerrainMaterialLayers {
land1_selector: None, base_selector: 3,
land2_selector: 3, overlay_selector: None,
} }
); );
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
assert_eq!( assert_eq!(
document.faces[0].material_layers().land2_selection(), document.faces[0].material_layers().base_selection(),
TerrainMaterialSelection { TerrainMaterialSelection {
table_index: 1, table_index: 0,
material_index: 3, 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] #[test]
@@ -1637,6 +1689,21 @@ mod tests {
assert_eq!(document.grid.compact_cells, [0x0040_0000]); assert_eq!(document.grid.compact_cells, [0x0040_0000]);
} }
#[test]
fn land_map_bounds_area_and_grid_reservations_by_payload() {
let empty = build_nres(&[entry(TYPE_AREAL_MAP, u32::MAX, 0, &[])]);
let nres = decode_nres(&empty).expect("nres");
assert!(decode_land_map(&nres).is_err());
let mut payload = Vec::new();
push_areal_prefix(&mut payload, 0, 0);
push_u32(&mut payload, 65_535);
push_u32(&mut payload, 65_535);
let bytes = build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]);
let nres = decode_nres(&bytes).expect("nres");
assert!(decode_land_map(&nres).is_err());
}
#[test] #[test]
fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() { 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 nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
+70 -3
View File
@@ -1,14 +1,16 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Validated terrain runtime queries using XY ground coordinates and Z height. //! 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; use std::collections::VecDeque;
/// Terrain material-selector contract preserved from the decoded map mesh. /// Terrain material-selector contract preserved from the decoded map mesh.
/// ///
/// Applications access this semantic terrain API through this terrain crate /// Applications access this semantic terrain API through this terrain crate
/// rather than taking a direct dependency on the binary-format parser. /// 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. /// Terrain world.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -141,6 +143,21 @@ pub trait SurfaceQuery {
direction: [f32; 3], direction: [f32; 3],
mask: FullSurfaceMask, mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError>; ) -> 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. /// Navigation query.
@@ -404,6 +421,28 @@ impl SurfaceQuery for TerrainWorld {
origin: [f32; 3], origin: [f32; 3],
direction: [f32; 3], direction: [f32; 3],
mask: FullSurfaceMask, 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> { ) -> Result<Option<SurfaceHit>, TerrainError> {
if self.surfaces.is_empty() { if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported); return Err(TerrainError::Unsupported);
@@ -413,7 +452,10 @@ impl SurfaceQuery for TerrainWorld {
let mut best: Option<SurfaceHit> = None; let mut best: Option<SurfaceHit> = None;
for index in candidates { for index in candidates {
let triangle = &self.surfaces[index]; 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; continue;
} }
let Some(distance) = triangle.raycast(origin, direction) else { let Some(distance) = triangle.raycast(origin, direction) else {
@@ -1032,6 +1074,31 @@ mod tests {
.expect("raycast"), .expect("raycast"),
None 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] #[test]
+19 -36
View File
@@ -10,7 +10,7 @@ messages, teleports, задачи, research и campaign transitions. Точки
и `briefing.cfg`. и `briefing.cfg`.
`.scr` — binary package с version checks, symbol/event sections и offsets; `.scr` — binary package с version checks, symbol/event sections и offsets;
полная opcode grammar не доказана. Его внешний framing теперь читает полная opcode grammar не доказана. Его внешний framing читает
`fparkan-script`: первый little-endian `u32` является числом required opcode `fparkan-script`: первый little-endian `u32` является числом required opcode
handlers, второй — числом event records. Каждый event хранит `name_len`, handlers, второй — числом event records. Каждый event хранит `name_len`,
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных `name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
@@ -108,24 +108,21 @@ unsupported result, а не «примерный» game command.
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer `<base>_Continue` только кэшируются в scheduler state; их фактический consumer
находится в отдельном позднем update path. Воспроизводимый read-only extractor: находится в отдельном позднем update path.
`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr` Во всех 58 GOG `.scr` имеются 6 087 instruction records, включая 3 992
имеются 6 087 instruction records, из них 3 992 sentinel; самый частый sentinel. Самый частый non-sentinel selector — `Handler(30)`, 246 records.
non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266` Его VA `0x1000c266` читает первые два reference words активной instruction,
читает первые два reference words активной instruction, разрешает каждый разрешает каждый через varset (`0x10002d30` и `0x10013570`) и вызывает внешний
через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с callback с тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
`1`; предметная семантика этих modes ещё не доказана. В частности, это пока `1`; предметная семантика этих modes ещё не доказана. В частности, это пока
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
text-to-varset resolver расположен за wrapper `0x10011ea0` в text-to-varset resolver расположен за wrapper `0x10011ea0` в
`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`, `0x100174a0`.
`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`.
Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый `0x100174a0` добавляет каждый
recognized source declaration в encounter order как 48-byte record; GOG shared recognized source declaration в encounter order как 48-byte record; GOG shared
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries `varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
@@ -134,10 +131,9 @@ capture. Полный GOG scan всех 246 Handler(30) instructions показ
operand references: все 492 in-range и указывают на `DWORD`. Поэтому operand references: все 492 in-range и указывают на `DWORD`. Поэтому
`VarSet::resolve_handler30` уже materializes точный opaque callback command `VarSet::resolve_handler30` уже materializes точный opaque callback command
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float, `(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
out-of-range и incomplete instructions вместо silent coercion. Extractors: out-of-range и incomplete instructions вместо silent coercion.
`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`.
Следующий static pass закрывает equality/update policy. Identity ровно равна Identity ровно равна
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и `(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`, `+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
@@ -147,7 +143,7 @@ slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode, изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
не назначает игровых имён и не делает event lookup за original VM. не назначает игровых имён и не делает event lookup за original VM.
На границе mission runtime выбранный TMA clan `first_resource` теперь На границе mission runtime выбранный TMA clan `first_resource`
материализуется как отдельный `MissionScriptBundle`: loader нормализует материализуется как отдельный `MissionScriptBundle`: loader нормализует
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable `<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
package вместе с clan provenance. Это именно wiring входных данных, не VM 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`. 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 `GetSuperAI` returns element `n` of the 64-pointer global table at preferred
`ai.dll + 0x55398` for `n <= 63`. The read-only `ai.dll + 0x55398` for `n <= 63`. A read-only capture of the running GOG
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values AutoDemo process observed `(500, 752, 0)` for entry 0 and `(728, 449, 1)` for
`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields entry 1 at fields
`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding `(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
profile. 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, 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 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 binds it to the proven creation/anchor path above; it does not guess the
remaining script event semantics. The associated Ghidra scripts are remaining script event semantics.
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
The creation and conversion boundaries are reproducible with
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
`ExportAiFtol.java`.
### Runtime Handler(30) operand binding ### 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 semantics of either resource ID; Rust therefore retains command `1` as
`Unhandled`. `Unhandled`.
Reproduce the callback and the command-one consumers with Runtime applies only this branch as
`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`,
`ExportIron3dAiCallbackCommand1.java`, and
`ExportIron3dAiCallbackCommand1Dispatch.java`.
Runtime now applies only this recovered branch as
`apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission `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 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 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. Both then write `+0x18`. Every other state simply writes the state word.
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned `VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
live record index, resolved state, and explicit reset kind. It does not invent 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 the table owner, the pre-reset helper, or callback semantics.
evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and
`ExportAiVmHandler8Transitions.java`.
### Handler(15): typed target-call boundary ### 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 return value remain unproven. Accordingly `VarSet::resolve_handler15` only
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`; materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
it never executes the opaque target call. Missing, out-of-range, wrong-type, it never executes the opaque target call. Missing, out-of-range, wrong-type,
and unobserved-mode inputs are explicit errors. Reproduce the static evidence and unobserved-mode inputs are explicit errors.
with `tools/ghidra/ExportAiVmHandler15.java`.
## Готовность ## Готовность
+7
View File
@@ -18,6 +18,13 @@ FParkan воспроизводит его работу на Rust и Vulkan. На
7. [Работа над движком](tomes/07-implementation.md): код, тесты и проверка результата. 7. [Работа над движком](tomes/07-implementation.md): код, тесты и проверка результата.
8. [Устройство оригинальной программы](tomes/08-evidence.md): DLL, адреса и конфигурация. 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/glossary.md) собраны термины, а в
[открытых вопросах](appendices/knowledge-boundaries.md) — ещё не восстановленное [открытых вопросах](appendices/knowledge-boundaries.md) — ещё не восстановленное
поведение. Описание алгоритма оригинала и возможности текущего приложения поведение. Описание алгоритма оригинала и возможности текущего приложения
+236
View File
@@ -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 и не получают придуманных
значений по умолчанию.
+203
View File
@@ -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#погода-осадки-и-звуковые-события).
+73 -7
View File
@@ -38,13 +38,9 @@ MAT0 имеет type ID `0x3054414D`, обычно расположен в `Mate
```c ```c
#pragma pack(push, 1) #pragma pack(push, 1)
struct Mat0PrefixV4Plus { struct Mat0Header {
uint16_t phase_count; uint16_t phase_count;
uint16_t animation_block_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 { struct Phase34 {
@@ -54,8 +50,78 @@ struct Phase34 {
#pragma pack(pop) #pragma pack(pop)
``` ```
Versioned fields читаются только если версия их содержит. Для старых версий `attr1` читается из NRes entry metadata и сохраняется вместе с разрешённым
используются runtime defaults, а raw values сохраняются. материалом. Это 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 ## Fallback
+79 -21
View File
@@ -19,7 +19,7 @@ type 10 strings and node names
type 13 Batch20 records type 13 Batch20 records
type 15 auxiliary stream type 15 auxiliary stream
type 17 auxiliary data type 17 auxiliary data
type 18 rare stream type 18 packed UV1 (optional secondary coordinates)
type 19 animation frame map type 19 animation frame map
type 20 rare auxiliary table type 20 rare auxiliary table
``` ```
@@ -40,23 +40,77 @@ struct Node38 {
}; };
``` ```
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает В исходном layout это 3 состояния по 5 LOD: `slot_index[state * 5 + lod]`.
отсутствие геометрии для комбинации LOD/group. Публичные 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`, `ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root; а `node38_fallback_hierarchy` собирает parent-before-child hierarchy.
иначе это parent index, обязательно меньший индекса child. Этот контракт `node38_sampled_hierarchy` использует type 19 map в пределах объявленного
подтверждён на тестах анимации с оригинальными ресурсами обеих частей и защищён fallback-ом: числа кадров и интерполирует соседние type 8 keys; при отсутствии usable map
модель с нарушенным порядком не получает придуманную hierarchy. или выходе за frame count используется fallback key. Для покомпонентного
sampling `node38_sampled_hierarchy_at_times` принимает отдельный optional float
time для каждого узла: `Some(time)` выбирает type 19 frame по округлению
`time - 0.5` к ближайшему целому с ties-to-even, затем интерполирует выбранный
type 8 key и его следующий key в исходный float time; `None` использует
fallback key. Значения должны быть конечными и неотрицательными. Это
portable-поведение при default-nearest rounding; полного совпадения со всеми
режимами округления x87 оно не заявляет. `parent_or_link == 0xFFFF`
означает root, иначе это parent index, который должен быть меньше индекса
child. Модель с нарушенным порядком не получает придуманную hierarchy.
В legacy-camera static preview стандартный узел уже получает свой fallback pose Type 8 записывает quaternion в порядке WXYZ, после decode `AnimKey24` хранит
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation, его как XYZW. Native AniMesh `+0x12560` строит local matrix через NGI32
затем translation суммируется, а rotations умножаются; после полученной global `g_FastProc + 0x38`; scalar implementation выдаёт transpose обычной active
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия `Pose` basis (`M01=2(xy+zw)`, `M02=2(xz-yw)`, `M10=2(xy-zw)`). Поэтому путь
намеренно дублируется на draw-range узла, потому что один source vertex может Node38 MSH сопрягает source-local quaternion ровно один раз после sampling и
быть нарисован разными node poses. Это static fallback hierarchy, а не полная до hierarchy composition (меняет знаки компонент `x`, `y`, `z`). Для fallback
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей. 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 ## Slot and batch
@@ -82,16 +136,20 @@ Type 13 задаёт draw ranges:
#pragma pack(push, 1) #pragma pack(push, 1)
struct Batch20 { struct Batch20 {
uint16_t batch_flags; uint16_t batch_flags;
uint16_t material_index; uint16_t material_index_hi; // +0x02, native high selector/slot field
uint16_t opaque4; uint8_t material_index; // +0x04, WEAR material selector
uint16_t opaque6; uint8_t lightmap_index; // +0x05, 0xFF means no lightmap
uint16_t local_batch_index; // +0x06
uint16_t index_count; uint16_t index_count;
uint32_t index_start; uint32_t index_start;
uint16_t opaque14; uint16_t vertex_count; // +0x0E
uint32_t base_vertex; uint32_t base_vertex;
}; };
#pragma pack(pop) #pragma pack(pop)
``` ```
Index check выполняется как `base_vertex + index < vertex_count` для всего Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
используемого slice. отдельно для native overrides. Loader проверяет, что
`index_start + index_count` остаётся внутри type 6, а каждый использованный
индекс через `base_vertex` попадает в position stream. `vertex_count` и
`local_batch_index` доступны renderer для исходного draw contract.
+8
View File
@@ -69,6 +69,14 @@ projection matrix contract.
## Parity risks ## 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; - x87 precision and rounding;
- scalar/SIMD `g_FastProc` differences; - scalar/SIMD `g_FastProc` differences;
- object, batch and transparent primitive order; - object, batch and transparent primitive order;
+55 -19
View File
@@ -359,9 +359,16 @@ local.set_translation(pose.position);
world[n] = world[parent(n)] * local; world[n] = world[parent(n)] * local;
``` ```
Для parity особенно важны x87-compatible округление при выборе frame index и Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа
порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле на 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: Проверки animation data:
@@ -611,11 +618,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж
diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию. diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию.
Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и
роботов, ограничения максимального числа, detail level и smoothing. Доказаны роботов, ограничения максимального числа, detail level и smoothing. Реализованы
shadow manager/pass, настройки detail/smoothing/count и зависимость от native visibility по шести Vulkan clip-плоскостям с радиусом sphere +100,
Terrain/CShade; полная формула projection geometry для каждого caster требует порядок кандидатов и лимит 20 до построения страниц, projected-size LOD,
dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по directional/point light contribution, native page layout, raster, smoothing и
именам, а не заменяются произвольными modern defaults. 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 для фоновых и погодных явлений. Atmosphere manager создаёт world objects для фоновых и погодных явлений.
Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain
@@ -624,6 +640,9 @@ background sound и обязательные ссылки на lightning effect.
требует screen position и occlusion test, rain -- области рядом с observer, требует screen position и occlusion test, rain -- области рядом с observer,
sound -- listener. Их нельзя один раз запечь в terrain. sound -- listener. Их нельзя один раз запечь в terrain.
Sun occlusion начинается от точки `camera + 0.5 * normalize(sun - camera)`;
этот offset является частью native query, а не произвольным bias renderer-а.
RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок. RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок.
Даже правильный средний интервал не даёт повторяемый кадр, если random values Даже правильный средний интервал не даёт повторяемый кадр, если random values
запрашиваются в другой последовательности. запрашиваются в другой последовательности.
@@ -677,11 +696,14 @@ settings ID сохраняются.
```text ```text
opcode = command_word & 0xFF opcode = command_word & 0xFF
enabled = (command_word >> 8) & 1 native_flag = (command_word >> 8) & 1
``` ```
Bits 9-31 являются частью данных и сохраняются. Между командами нет `native_flag` передаётся созданному native command object как режим/feature
выравнивания. Размер команды, включая word: flag и не отключает команду. Например, реальный `env_lightning` использует
`native_flag == 0` для opcode 3, opcode 1 и opcode 2, и все три команды
исполняются. Bits 9-31 являются частью данных и сохраняются. Между командами
нет выравнивания. Размер команды, включая word:
```text ```text
opcode 1 224 байта opcode 1 224 байта
@@ -896,8 +918,11 @@ Shade cache в GOG `Terrain.dll` имеет vtable RVA `0x643D0`. Его таб
находится по `+320`; банки начинаются с `+332`, их шаг — 212 байт. Lookup находится по `+320`; банки начинаются с `+332`, их шаг — 212 байт. Lookup
RVA `0x10910` возвращает временное представление по `+24`, а не саму запись. RVA `0x10910` возвращает временное представление по `+24`, а не саму запись.
Построение в RVA `0x10280` и `0x12e20` использует созданную загрузчиком геометрию. Построение в 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 ## Реализация Vulkan
@@ -905,12 +930,23 @@ RVA `0x10910` возвращает временное представление
получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через
push constant; он не требует нового pipeline. push constant; он не требует нового pipeline.
Статический путь `fparkan-game` загружает ландшафт и MSH-компоненты выбранных Рабочий путь `fparkan-game` загружает terrain и все выбранные mission roots
объектов. Локальные material slots разрешаются через WEAR и MAT0 перед (по умолчанию весь список объектов), разрешая локальные material slots через
объединением геометрии. Индексы GPU имеют тип `u32`: вся карта может содержать WEAR и MAT0 до объединения геометрии. Индексы GPU имеют тип `u32`: вся карта
больше 65 535 вершин, даже если каждый исходный mesh использует `u16`. может содержать больше 65 535 вершин, даже если каждый исходный mesh использует
Текущий путь берёт базовую текстуру Land2; свет, составные слои и атмосфера `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 Для сохранения кадра surface должен поддерживать `TRANSFER_SRC`. Renderer
копирует последний отправленный swapchain image в host-visible buffer и копирует последний отправленный swapchain image в host-visible buffer и
+169 -34
View File
@@ -143,7 +143,7 @@ references каждого record, жёстко ограничивает counts/a
файла, но не таблица семантик: названия opcode/words появятся только после файла, но не таблица семантик: названия opcode/words появятся только после
handler contracts и runtime traces. handler contracts и runtime traces.
Связь первого header word с dispatch теперь доказана статически: `ai.dll` Связь первого header word с dispatch доказана статически: `ai.dll`
создаёт 73 handler pointers в известном порядке и копирует table без создаёт 73 handler pointers в известном порядке и копирует table без
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed `0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
@@ -201,16 +201,6 @@ Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evalu
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
или связь чисел tag с языковыми операторами. или связь чисел 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 и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
@@ -292,9 +282,7 @@ Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI фор
а mode передаётся последним; decompiler не восстанавливает предметные имена а mode передаётся последним; decompiler не восстанавливает предметные имена
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
`0x42478`. Reproducible extractor находится в `0x42478`.
`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
в локальном Ghidra project и не изменяет оригинальную DLL.
Именно update methods этих private objects, а не пять exports, остаются Именно update methods этих private objects, а не пять exports, остаются
следующим объектом динамической трассировки. Поэтому reference movement в следующим объектом динамической трассировки. Поэтому reference movement в
@@ -330,8 +318,7 @@ interface сразу получает пять virtual calls, связывающ
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем `+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
связывается с `+0x170`. Это достаточное основание хранить будущий Control связывается с `+0x170`. Это достаточное основание хранить будущий Control
component как ordered raw-string/resource provenance, но не для присвоения component как ordered raw-string/resource provenance, но не для присвоения
этим строкам смысловых имён до трассировки private update methods. Extractor: этим строкам смысловых имён до трассировки private update methods.
`tools/ghidra/ExportAniMeshControlCaller.java`.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
@@ -393,22 +380,36 @@ Collision manager не должен хранить прямую незащищё
### CTLD и physical resources ### CTLD и physical resources
Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources. Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources.
В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять В заголовке CTLD идут пять `u32`; обозначим первые три counts как `S`, `M` и
внутренних счётчиков образуют множество вариантов: наиболее частый размер `T`. Native layout задаёт начало control-row table формулой
392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до `128 + S * (156 + 16 * M) + 4 * S * S`; за ним следуют `T` records по 36 байт.
1868 байт и более сложные комбинации. В record известны node ID (`i32`, `+0`), два endpoint frame (`f32`, `+4`, `+8`),
начальный blend (`f32`, `+0x0c`) и raw flags (`u32`, `+0x20`). Остальные поля
нельзя выводить из этой позовой привязки.
CTLD является составным count-driven форматом, а не фиксированной struct. Пример `fr_l_plant.ctl` имеет counts `[14, 0, 5, 11, 13]`: формула даёт
Parser должен: `row_start = 3096 (0xC18)`, а пять control rows заканчиваются на `+0xCCC`.
GOG `Control.dll` function RVA `0x9950` читает blend из `+0x0c` и flags из
`+0x20`. Его branch RVA `0x99C4..0x9A66` применяет flags: при `flags & 0x1`
blend один раз переносится через границу — из значения выше `1` вычитается
`1`, к значению ниже `0` прибавляется `1`; без этого флага blend ограничивается
`[0, 1]`. Затем `flags & 0x2` инвертирует значение (`1 - blend`). Константы
`Control.dll+0x3B188 = 1.0` и `+0x3B18C = 0.0` подтверждают границы этих
сравнений.
- прочитать prefix и все счётчики с проверкой переполнения; Control в call site RVA `0x99B5` передаёт в AniMesh через slot `+0x30` пару A
- вычислить границы секций по их counts; `[-1, -1]` и пару B из endpoint полей строки. В call site `0x9A7B` slot `+0x28`
- сохранять неизвестные records в typed raw containers; сохраняет нормализованный blend по `+0x114` и weight `1` по `+0x118`. AniMesh
- требовать точного завершения payload; update RVA `0x8BF2..0x8C76` вычисляет
- не использовать размер одного популярного варианта как универсальный layout. `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 ### Terrain queries и movement handoff
@@ -452,10 +453,8 @@ exports; RVA всех пяти exports изменились. Форматы и c
сохранились, но точное physical/collision behavior нельзя считать побайтно тем сохранились, но точное physical/collision behavior нельзя считать побайтно тем
же. же.
CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено; Покадровая привязка выше описывает начальную позу компонента. Она не задаёт
большинство общих CTLD изменено вместе с переработанными моделями. Это семантику CTLD shape records и не восстанавливает contact solver.
подтверждает count-driven parser, но не закрывает предметную семантику shape
records и contact solver.
Differential test обеих частей должен воспроизводить движение без препятствий, Differential test обеих частей должен воспроизводить движение без препятствий,
slope following, pair collision, timing collision event и удаление объекта в slope following, pair collision, timing collision event и удаление объекта в
@@ -569,6 +568,142 @@ Ngi32 создаёт низкоуровневый DirectSound backend. `services
`ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы: `ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы:
воспроизводят 2D/3D sources, меняют volume и связывают listener с camera. воспроизводят 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: Публичные функции Ngi32:
```text ```text
+3 -2
View File
@@ -28,8 +28,9 @@ FParkan развивается небольшими законченными и
Оригинальные ресурсы остаются в установленной игре. Локальные тесты с ними Оригинальные ресурсы остаются в установленной игре. Локальные тесты с ними
помечены `#[ignore]`, чтобы обычная проверка работала без коммерческих файлов. помечены `#[ignore]`, чтобы обычная проверка работала без коммерческих файлов.
Сообщение об ошибке должно назвать ресурс и причину: например, какой материал Сообщение об ошибке должно сразу назвать ресурс и причину: например, какой
сослался на отсутствующую текстуру. Отдельный отчёт для каждого запуска не нужен. материал сослался на отсутствующую текстуру. Поэтому диагностика остаётся
частью самого запуска и не зависит от отдельного отчёта.
## Числа и порядок вычислений ## Числа и порядок вычислений
+18 -77
View File
@@ -241,45 +241,20 @@ near/far mapping, handedness или initial camera selection. Важно, что
`ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call `ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call
stubs и не дают usable ABI. stubs и не дают usable ABI.
Live elevated read-only probe впервые подтвердил relocation-aware runtime связь: Глобальная camera boundary имеет более точное статическое описание.
`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал `stdGetCurrentCamera2` — короткий getter Terrain по RVA `0x4FD80`, который
non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно читает указатель из global RVA `0x7355C`; initializer по RVA `0x4D4D0`
relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает запрашивает selector `8` у своего `this` и сохраняет полученный interface
live camera object с outer vtable, но одновременно исправляет прежнее слишком pointer. Запрос selector `18` у landscape object проходит через RVA `0x106D0`
сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix, и `0x107E0`, возвращает subobject по `base + 0x138`.
а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/
`0x4D9C0` и exported global ещё не доказаны как один layout без interface
adjustment; их offsets остаются unassigned до recovery selector relationship.
Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это Внешняя camera vtable по RVA `0x665B4` имеет slot `+0x54`, который вызывает у
шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA subobject `outer + 0x4` slot `+0x20` с selector `0` и копирует возвращённые
`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain компоненты `+0x0C`, `+0x1C` и `+0x2C`. При значении selector field
по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет `outer + 0x10 = -1` selector `0` возвращает `outer + 0x20`; selector `2`
полученный interface pointer. Это доказывает адрес хранения и путь заполнения, возвращает `outer + 0x60`, а slot `+0x70` выводит углы через `atan2`.
но не разрешает трактовать pointer как конкретный layout камеры либо читать его Это фиксирует пути чтения положения и ориентации, но не назначает имена полям,
как runtime evidence без доступа к процессу на том же уровне привилегий. порядок углов или handedness.
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 без догадок.
### Vtable и interface negotiation ### Vtable и interface negotiation
@@ -288,6 +263,11 @@ backend-neutral camera pose без догадок.
world traversal; camera и viewport получаются через selector-based interface world traversal; camera и viewport получаются через selector-based interface
calls; shared objects используют ранний slot как AddRef-подобную операцию. 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 и число аргументов. 1. Зафиксировать byte offset slot и число аргументов.
@@ -299,45 +279,6 @@ calls; shared objects используют ранний slot как AddRef-по
Нельзя добавлять virtual destructor в начало reconstructed interface: это Нельзя добавлять virtual destructor в начало reconstructed interface: это
сдвинет все slots. сдвинет все 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 ### Каталог как внешний API
+2
View File
@@ -73,6 +73,8 @@ nav:
- WEAR и MAT0: reference/materials.md - WEAR и MAT0: reference/materials.md
- Texm: reference/texm.md - Texm: reference/texm.md
- Render frame: reference/render-frame.md - Render frame: reference/render-frame.md
- Атмосфера и небо: reference/atmosphere.md
- Эффекты окружения: reference/environment-effects.md
- Оригинальные модули: reference/original-binaries.md - Оригинальные модули: reference/original-binaries.md
- Приложения: - Приложения:
- Глоссарий: appendices/glossary.md - Глоссарий: appendices/glossary.md