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
Открытый движок *Паркан: Железная стратегия* на Rust и Vulkan. Он читает
ресурсы установленной оригинальной игры. Сейчас приложение загружает миссию
и показывает её ландшафт и модели в статическом окне Vulkan.
ресурсы установленной оригинальной игры. Сейчас приложение показывает
интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты
объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
события. Это просмотр карты с полной графической сценой; прохождение миссии и
игровое поведение юнитов пока не запускаются, а модели показываются в статической
позе, заданной сохранёнными настройками узлов прототипа.
## Запуск
@@ -10,16 +14,45 @@
репозиторий не входят.
```powershell
cargo run -p fparkan-game -- `
cargo run --release -p fparkan-game -- `
--root 'C:\GOG Games\Parkan - Iron Strategy' `
--mission 'MISSIONS\Autodemo.00\data.tma' --frames 240
--mission 'MISSIONS\Autodemo.00\data.tma'
```
`--frames` задаёт число кадров. `--preview-roots N` ограничивает число объектов,
`--legacy-camera-capture` читает сохранённую матрицу камеры,
`--static-animation-frame` и `--static-material-phase` выбирают позу и фазу
материала. `--readback-out target/frame.raw` сохраняет последний кадр GPU.
Без сохранённой камеры используется вид карты сверху.
Окно работает в интерактивном режиме до закрытия и по умолчанию загружает все
корневые объекты миссии (`--preview-roots N` ограничивает их число только для
диагностики).
`WASD` перемещают камеру относительно её направления, `E/Q` поднимают и
опускают её, `Shift` ускоряет, `Ctrl` замедляет движение. Удержание правой
кнопки мыши включает relative-look; движение мыши вращает yaw/pitch. При
потере фокуса обзор отключается и нажатые клавиши сбрасываются. `Esc`
отпускает обзор или закрывает окно.
В каталоге миссии ищется `sky.ske`. Если файл найден, приложение читает её
расписание и `sky.wea`; связанные MAT0/WEAR-материалы и
`effects.rlb/env_lightning` загружаются из ресурсов игры. Без `sky.ske`
окружение отключено; если файл есть, отсутствие `sky.wea`, `env_lightning` или
связанного материала считается ошибкой. Параметр `--atmosphere-seconds N`
задаёт начальное время; по умолчанию используется начало расписания. Когда
окружение загружено, небо, солнце и
луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные
звуковые события обновляются в каждом кадре.
`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок;
значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture`
читает сохранённый захват камеры и запускает воспроизводимый режим без
free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для
всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
используется кадр `0`. Статическая поза не запускает игровой Control, AI или
полное поведение юнитов.
`--static-material-phase <u16>` выбирает фиксированную фазу материала.
`--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation`
включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении
или ошибке. Readback сохраняет байты в формате surface без преобразования;
размер — `width × height × 4`. Порядок каналов и encoding определяются значением
`readback_format` в выводе; на текущем GPU обычно используется
`VK_FORMAT_R8G8B8A8_UNORM`.
## Разработка
@@ -2,18 +2,132 @@
layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec2 in_detail_uv;
layout(location = 3) in float in_overlay_alpha;
layout(location = 4) in float in_fog_factor;
layout(location = 5) in vec3 in_base_diffuse;
layout(location = 6) in vec3 in_base_specular;
layout(location = 7) in vec3 in_overlay_diffuse;
layout(location = 8) in vec3 in_overlay_specular;
layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_color;
layout(set = 0, binding = 1) uniform sampler2D base_detail;
layout(set = 0, binding = 2) uniform sampler2D overlay_color;
layout(set = 0, binding = 3) uniform sampler2D overlay_detail;
layout(push_constant) uniform AlphaTestConstants {
layout(offset = 64)
float alpha_cutoff;
} alpha_test;
layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world;
vec4 directional_direction[4];
vec4 directional_rgb[4];
vec4 directional_coefficients[4];
vec4 point_position_range;
vec4 point_rgb_active;
vec4 point_coefficients_active;
vec4 lighting_floor;
vec4 fog_color;
vec4 fog_distances;
vec4 camera_position;
} frame;
layout(push_constant) uniform MaterialConstants {
layout(offset = 0) float alpha_cutoff;
layout(offset = 4) float diffuse_alpha;
layout(offset = 8) float overlay_diffuse_alpha;
layout(offset = 12) float directional_r;
layout(offset = 16) float directional_g;
layout(offset = 20) float directional_b;
layout(offset = 24) float additive_r;
layout(offset = 28) float additive_g;
layout(offset = 32) float additive_b;
layout(offset = 36) float overlay_directional_r;
layout(offset = 40) float overlay_directional_g;
layout(offset = 44) float overlay_directional_b;
layout(offset = 48) float overlay_additive_r;
layout(offset = 52) float overlay_additive_g;
layout(offset = 56) float overlay_additive_b;
layout(offset = 60) float base_page_x;
layout(offset = 64) float base_page_y;
layout(offset = 68) float base_page_w;
layout(offset = 72) float base_page_h;
layout(offset = 76) float detail_page_x;
layout(offset = 80) float detail_page_y;
layout(offset = 84) float detail_page_w;
layout(offset = 88) float detail_page_h;
layout(offset = 92) float overlay_page_x;
layout(offset = 96) float overlay_page_y;
layout(offset = 100) float overlay_page_w;
layout(offset = 104) float overlay_page_h;
layout(offset = 108) float overlay_detail_page_x;
layout(offset = 112) float overlay_detail_page_y;
layout(offset = 116) float overlay_detail_page_w;
layout(offset = 120) float overlay_detail_page_h;
layout(offset = 124) float material_mode;
} material;
void main() {
out_color = texture(base_color, in_uv) * vec4(in_color, 1.0);
if (out_color.a < alpha_test.alpha_cutoff) {
vec2 base_uv = vec2(material.base_page_x, material.base_page_y)
+ in_uv * vec2(material.base_page_w, material.base_page_h);
vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y)
+ in_detail_uv * vec2(material.detail_page_w, material.detail_page_h);
vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y)
+ in_uv * vec2(material.overlay_page_w, material.overlay_page_h);
vec2 overlay_detail_uv = vec2(material.overlay_detail_page_x, material.overlay_detail_page_y)
+ in_detail_uv * vec2(material.overlay_detail_page_w, material.overlay_detail_page_h);
vec4 base = texture(base_color, base_uv);
vec4 detail = texture(base_detail, detail_uv);
vec4 overlay = texture(overlay_color, overlay_uv);
vec4 overlay_detail_sample = texture(overlay_detail, overlay_detail_uv);
int mode = int(round(material.material_mode));
int combiner_mode = mode & 3;
// Native sky mode 4 combines nebula and stars as
// mix(TEX0.rgb, TEX1.rgb, TEX1.a) * DIFFUSE.rgb and carries DIFFUSE.a
// through as the pass alpha.
if (combiner_mode == 2) {
vec3 sky_rgb = mix(base.rgb, detail.rgb, clamp(detail.a, 0.0, 1.0));
out_color = vec4(sky_rgb * in_color, clamp(in_overlay_alpha, 0.0, 1.0));
if (out_color.a < material.alpha_cutoff) {
discard;
}
return;
}
// Native terrain mode 3 combines the base texture and the lightmap
// without the regular terrain 2x combiner or NdotL calculation.
if (combiner_mode == 3) {
vec3 lightmap_lighting = max(
vec3(material.additive_r, material.additive_g, material.additive_b),
frame.lighting_floor.rgb
);
vec3 vertex_rgb = base.rgb * detail.rgb * lightmap_lighting * in_color;
vec3 final_rgb = mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor);
out_color = vec4(final_rgb, base.a * material.diffuse_alpha);
if (out_color.a < material.alpha_cutoff) {
discard;
}
return;
}
float overlay_alpha = clamp(
in_overlay_alpha * material.overlay_diffuse_alpha,
0.0,
1.0
);
vec3 base_rgb = 2.0 * base.rgb * detail.rgb;
vec3 overlay_rgb = 2.0 * overlay.rgb * overlay_detail_sample.rgb;
vec3 base_lit_rgb = base_rgb * in_base_diffuse + in_base_specular;
vec3 overlay_lit_rgb = overlay_rgb * in_overlay_diffuse + in_overlay_specular;
vec3 lit_rgb = mix(base_lit_rgb, overlay_lit_rgb, overlay_alpha);
vec3 vertex_rgb = lit_rgb * in_color;
vec3 final_rgb = combiner_mode == 0
? mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor)
: vertex_rgb;
float final_alpha = combiner_mode == 1
? base.a * material.diffuse_alpha * in_overlay_alpha
: base.a * material.diffuse_alpha;
out_color = vec4(final_rgb, final_alpha);
if (out_color.a < material.alpha_cutoff) {
discard;
}
}
@@ -2,17 +2,273 @@
layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_color;
layout(location = 2) in vec2 in_uv;
layout(location = 2) in vec3 in_normal;
layout(location = 3) in vec2 in_uv;
layout(location = 4) in vec2 in_detail_uv;
layout(location = 5) in float in_overlay_alpha;
layout(location = 0) out vec3 out_color;
layout(location = 1) out vec2 out_uv;
layout(location = 2) out vec2 out_detail_uv;
layout(location = 3) out float out_overlay_alpha;
// Native D3D vertex fog is written to SPECULAR.a and interpolated by the
// fixed-function rasterizer. Keep the existing varying slot, but carry only
// that scalar instead of recomputing distance in the fragment stage.
layout(location = 4) out float out_fog_factor;
layout(location = 5) out vec3 out_base_diffuse;
layout(location = 6) out vec3 out_base_specular;
layout(location = 7) out vec3 out_overlay_diffuse;
layout(location = 8) out vec3 out_overlay_specular;
layout(push_constant) uniform CameraConstants {
layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world;
} camera;
vec4 directional_direction[4];
vec4 directional_rgb[4];
vec4 directional_coefficients[4];
vec4 point_position_range;
vec4 point_rgb_active;
vec4 point_coefficients_active;
vec4 lighting_floor;
vec4 fog_color;
vec4 fog_distances;
vec4 camera_position;
} frame;
layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
vec4 base;
vec4 overlay;
} material_specular;
layout(push_constant) uniform MaterialConstants {
layout(offset = 0) float alpha_cutoff;
layout(offset = 4) float diffuse_alpha;
layout(offset = 8) float overlay_diffuse_alpha;
layout(offset = 12) float directional_r;
layout(offset = 16) float directional_g;
layout(offset = 20) float directional_b;
layout(offset = 24) float additive_r;
layout(offset = 28) float additive_g;
layout(offset = 32) float additive_b;
layout(offset = 36) float overlay_directional_r;
layout(offset = 40) float overlay_directional_g;
layout(offset = 44) float overlay_directional_b;
layout(offset = 48) float overlay_additive_r;
layout(offset = 52) float overlay_additive_g;
layout(offset = 56) float overlay_additive_b;
layout(offset = 60) float base_page_x;
layout(offset = 64) float base_page_y;
layout(offset = 68) float base_page_w;
layout(offset = 72) float base_page_h;
layout(offset = 76) float detail_page_x;
layout(offset = 80) float detail_page_y;
layout(offset = 84) float detail_page_w;
layout(offset = 88) float detail_page_h;
layout(offset = 92) float overlay_page_x;
layout(offset = 96) float overlay_page_y;
layout(offset = 100) float overlay_page_w;
layout(offset = 104) float overlay_page_h;
layout(offset = 108) float overlay_detail_page_x;
layout(offset = 112) float overlay_detail_page_y;
layout(offset = 116) float overlay_detail_page_w;
layout(offset = 120) float overlay_detail_page_h;
layout(offset = 124) float material_mode;
} material;
struct LightingTerms {
vec3 diffuse;
vec3 specular;
};
float native_range_compress(float value) {
if (value <= 1.0) {
return value;
}
if (value <= 7.0) {
return value / 6.0 + 5.0 / 6.0;
}
return 2.0;
}
float native_specular_curve(float value) {
if (value <= 1.0) {
return 0.8 * value;
}
if (value <= 3.0) {
return 0.1 * value + 0.7;
}
return 1.0;
}
// The original callback uses the reflected vector and squares the cosine for
// power-1 iterations. A bounded loop keeps the native byte power while
// remaining valid for every material record.
float native_specular_power(float cosine, float power) {
// CShade enables specular only when the material power byte is non-zero.
if (power <= 0.0 || cosine <= 0.0) {
return 0.0;
}
float result = cosine;
int iterations = clamp(int(power) - 1, 0, 254);
for (int index = 0; index < 254; ++index) {
if (index >= iterations) {
break;
}
result *= result;
}
return result;
}
float native_fog_factor(vec3 world_position) {
float fog_span = frame.fog_distances.y - frame.fog_distances.x;
float distance_to_camera = distance(world_position, frame.camera_position.xyz);
if (fog_span <= 0.000001 || distance_to_camera <= frame.fog_distances.x) {
return 0.0;
}
return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0);
}
vec3 normalized_or(vec3 value, vec3 fallback) {
float length_squared = dot(value, value);
return length_squared > 0.0000001 ? value * inversesqrt(length_squared) : fallback;
}
vec3 specular_from_light(
vec3 normal,
vec3 view_direction,
vec3 light_direction,
vec3 light_rgb,
vec3 material_rgb,
float power,
float attenuation
) {
float ndotl = dot(normal, light_direction);
if (ndotl <= 0.0 || attenuation <= 0.0) {
return vec3(0.0);
}
vec3 reflection = reflect(-light_direction, normal);
float cosine = max(dot(reflection, view_direction), 0.0);
return material_rgb * light_rgb * native_specular_power(cosine, power) * attenuation;
}
LightingTerms native_material_lighting(
vec3 world_position,
vec3 normal,
vec3 directional_rgb,
vec3 additive_rgb,
vec3 specular_rgb,
float specular_power
) {
vec3 accumulated = additive_rgb;
vec3 specular = vec3(0.0);
vec3 view_direction = normalized_or(frame.camera_position.xyz - world_position, vec3(0.0, 0.0, 1.0));
for (int index = 0; index < 4; ++index) {
vec4 direction_record = frame.directional_direction[index];
vec4 rgb_record = frame.directional_rgb[index];
if (direction_record.w <= 0.5 || rgb_record.w <= 0.5) {
continue;
}
vec3 light_direction = normalized_or(-direction_record.xyz, vec3(0.0, 0.0, 1.0));
float ndotl = max(dot(normal, light_direction), 0.0);
accumulated += ndotl * directional_rgb * rgb_record.rgb;
specular += specular_from_light(
normal,
view_direction,
light_direction,
rgb_record.rgb,
specular_rgb,
specular_power,
1.0
);
}
vec4 point_position_range = frame.point_position_range;
vec4 point_rgb_active = frame.point_rgb_active;
vec4 point_coefficients_active = frame.point_coefficients_active;
vec3 to_light = point_position_range.xyz - world_position;
float distance_squared = dot(to_light, to_light);
float range = point_position_range.w;
if (point_rgb_active.w > 0.5 && range > 0.0 && distance_squared <= range * range) {
float distance_to_light = sqrt(distance_squared);
if (distance_to_light > 0.000001) {
vec3 light_direction = to_light / distance_to_light;
float ndotl = dot(normal, light_direction);
if (ndotl > 0.0) {
float t = (range - distance_to_light) / range;
float attenuation = point_coefficients_active.x
+ point_coefficients_active.y * t
+ point_coefficients_active.z * t * t;
attenuation = max(attenuation, 0.0);
accumulated += ndotl * directional_rgb * point_rgb_active.rgb * attenuation;
specular += specular_from_light(
normal,
view_direction,
light_direction,
point_rgb_active.rgb,
specular_rgb,
specular_power,
attenuation
);
}
}
}
accumulated = max(accumulated, frame.lighting_floor.rgb);
vec3 compressed = vec3(
native_range_compress(accumulated.r),
native_range_compress(accumulated.g),
native_range_compress(accumulated.b)
);
LightingTerms terms;
terms.diffuse = min(compressed, vec3(1.0));
vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));
terms.specular = vec3(
native_specular_curve(total_specular.r),
native_specular_curve(total_specular.g),
native_specular_curve(total_specular.b)
);
return terms;
}
void main() {
out_color = in_color;
out_uv = in_uv;
gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
out_detail_uv = in_detail_uv;
out_overlay_alpha = in_overlay_alpha;
out_fog_factor = native_fog_factor(in_position);
int mode = int(round(material.material_mode));
int combiner_mode = mode & 3;
if (combiner_mode == 0) {
LightingTerms base_terms = native_material_lighting(
in_position,
in_normal,
vec3(material.directional_r, material.directional_g, material.directional_b),
vec3(material.additive_r, material.additive_g, material.additive_b),
material_specular.base.rgb,
material_specular.base.w
);
LightingTerms overlay_terms = native_material_lighting(
in_position,
in_normal,
vec3(material.overlay_directional_r, material.overlay_directional_g, material.overlay_directional_b),
vec3(material.overlay_additive_r, material.overlay_additive_g, material.overlay_additive_b),
material_specular.overlay.rgb,
material_specular.overlay.w
);
out_base_diffuse = base_terms.diffuse;
out_base_specular = base_terms.specular;
out_overlay_diffuse = overlay_terms.diffuse;
out_overlay_specular = overlay_terms.specular;
} else {
out_base_diffuse = vec3(1.0);
out_base_specular = vec3(0.0);
out_overlay_diffuse = vec3(1.0);
out_overlay_specular = vec3(0.0);
}
vec4 clip_position = frame.clip_from_world * vec4(in_position, 1.0);
// Sky far-depth is a Vulkan projection choice for layers authored around
// the camera. Keep world positions, normals, and UVs intact while placing
// the resulting primitive at the far depth; this does not assert native
// D3D depth-bit equivalence.
if ((mode & 4) != 0) {
clip_position.z = clip_position.w;
}
gl_Position = clip_position;
}
File diff suppressed because it is too large Load Diff
+28 -11
View File
@@ -21,16 +21,22 @@ mod swapchain_resources;
mod validation;
pub use self::asset_mesh::{
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space,
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh,
project_land_msh_to_static_mesh_in_legacy_world_space,
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount,
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer,
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount,
project_msh_to_static_mesh_in_world_space_with_transform,
project_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
VulkanStaticXyFrame,
VulkanNodePose, VulkanStaticXyFrame,
};
pub use self::capabilities::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
@@ -45,21 +51,27 @@ pub use self::instance::{
use self::instance::{cstring_vec, ensure_instance_extensions_available};
use self::resources::{
color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync,
create_frame_uniform_buffer, create_index_buffer, create_material_specular_buffer,
create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer,
create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image,
destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage,
VulkanDepthAttachment, VulkanFrameSync,
create_static_texture_image, create_vertex_buffer, destroy_allocated_buffer,
destroy_allocated_image, destroy_depth_attachment, readback_buffer_bytes,
update_static_texture_image, write_frame_uniform, write_material_specular_buffer,
write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer,
VulkanAllocatedImage, VulkanDepthAttachment, VulkanFrameSync,
};
pub use self::runtime::{
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
};
pub use self::smoke_types::{
VulkanDirectionalLight, VulkanDynamicDrawRange, VulkanFrameUniforms, VulkanPointLight,
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport,
VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, VulkanValidationReport,
VULKAN_DIRECTIONAL_LIGHT_COUNT, VULKAN_FRAME_UNIFORM_BYTES,
VULKAN_MATERIAL_SPECULAR_RECORD_BYTES,
};
#[cfg(test)]
use self::surface::extension_name;
@@ -72,7 +84,8 @@ pub use self::swapchain::{
VulkanSwapchainProbeError, VulkanSwapchainReport,
};
use self::swapchain_resources::{
create_swapchain_resources, destroy_swapchain_resources, VulkanSwapchainResources,
create_swapchain_resources, destroy_swapchain_resources, update_material_texture_descriptor,
VulkanSwapchainResources,
};
use self::validation::{create_validation_messenger, VulkanValidationMessenger};
use ash::vk;
@@ -469,6 +482,8 @@ const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
];
const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT] {
assert!(bytes.len() % 4 == 0);
assert!(bytes.len() == WORD_COUNT * 4);
let mut words = [0_u32; WORD_COUNT];
let mut index = 0;
while index < WORD_COUNT {
@@ -484,10 +499,12 @@ const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT]
words
}
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] =
spirv_words(include_bytes!("../shaders/triangle.vert.spv"));
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] =
spirv_words(include_bytes!("../shaders/triangle.frag.spv"));
const TRIANGLE_VERTEX_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.vert.spv");
const TRIANGLE_FRAGMENT_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.frag.spv");
static TRIANGLE_VERTEX_SHADER_DATA: [u32; TRIANGLE_VERTEX_SHADER_BYTES.len() / 4] =
spirv_words(TRIANGLE_VERTEX_SHADER_BYTES);
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; TRIANGLE_FRAGMENT_SHADER_BYTES.len() / 4] =
spirv_words(TRIANGLE_FRAGMENT_SHADER_BYTES);
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA;
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA;
@@ -18,6 +18,9 @@ pub(super) struct VulkanAllocatedImage {
pub(super) image: vk::Image,
pub(super) memory: vk::DeviceMemory,
pub(super) view: vk::ImageView,
pub(super) width: u32,
pub(super) height: u32,
pub(super) mip_levels: u32,
}
/// Depth/stencil image and the exact format selected for its render pass.
@@ -134,6 +137,9 @@ pub(super) fn create_depth_attachment(
image,
memory,
view,
width: extent.0,
height: extent.1,
mip_levels: 1,
},
format,
})
@@ -186,7 +192,6 @@ fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags {
pub(super) struct VulkanFrameSync {
pub(super) image_available: vk::Semaphore,
pub(super) render_finished: vk::Semaphore,
pub(super) fence: vk::Fence,
}
@@ -210,17 +215,7 @@ pub(super) fn create_static_mesh_vertex_buffer(
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
for vertex in &mesh.vertices {
for value in vertex
.position
.into_iter()
.chain(vertex.color)
.chain(vertex.uv)
{
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
let bytes = encode_static_vertices(&mesh.vertices)?;
create_host_visible_buffer(
instance,
device,
@@ -230,34 +225,176 @@ pub(super) fn create_static_mesh_vertex_buffer(
)
}
/// Allocates a vertex buffer for a frame's retained mesh capacity.
pub(super) fn create_vertex_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
vertices: &[super::VulkanStaticVertex],
) -> Result<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(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
for &index in &mesh.indices {
bytes.extend_from_slice(&index.to_ne_bytes());
}
create_index_buffer(instance, device, &mesh.indices)
}
/// Allocates an index buffer for a frame's retained mesh capacity.
pub(super) fn create_index_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
indices: &[u32],
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let bytes = encode_static_indices(indices)?;
create_host_visible_buffer(
instance,
device,
&bytes,
vk::BufferUsageFlags::INDEX_BUFFER,
"static mesh index buffer",
"dynamic mesh index buffer",
)
}
/// Replaces the bytes of a host-visible static index allocation.
pub(super) fn write_static_mesh_index_buffer(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
indices: &[u32],
) -> Result<(), VulkanSmokeRendererError> {
let bytes = encode_static_indices(indices)?;
write_mapped_buffer(
device,
buffer,
&bytes,
"vkMapMemory(static mesh index update)",
)
}
fn encode_static_indices(indices: &[u32]) -> Result<Vec<u8>, VulkanSmokeRendererError> {
let byte_len = indices
.len()
.checked_mul(std::mem::size_of::<u32>())
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "static mesh index data size overflows address space",
})?;
let mut bytes = Vec::new();
bytes
.try_reserve_exact(byte_len)
.map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
context: "static mesh index data exceeds addressable memory",
})?;
for &index in indices {
bytes.extend_from_slice(&index.to_ne_bytes());
}
Ok(bytes)
}
pub(super) fn create_static_texture_image(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
texture: &VulkanStaticTexture,
) -> Result<VulkanAllocatedImage, VulkanSmokeRendererError> {
texture
.validate()
.map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?;
let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture);
let staging = create_host_visible_buffer(
instance,
device,
&texture.rgba8,
&mip_bytes,
vk::BufferUsageFlags::TRANSFER_SRC,
"static texture staging buffer",
)?;
@@ -269,7 +406,7 @@ pub(super) fn create_static_texture_image(
height: texture.height,
depth: 1,
})
.mip_levels(1)
.mip_levels(mip_levels)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
@@ -336,6 +473,7 @@ pub(super) fn create_static_texture_image(
image,
texture.width,
texture.height,
mip_levels,
) {
// SAFETY: Both resources were created on this device and are being rolled back once.
unsafe {
@@ -350,7 +488,7 @@ pub(super) fn create_static_texture_image(
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.subresource_range(color_subresource_range());
.subresource_range(color_subresource_range(mip_levels));
// SAFETY: The image is live, initialized and has the stated color subresource.
let view = {
// SAFETY: The image is live, initialized and has the stated color subresource.
@@ -371,9 +509,211 @@ pub(super) fn create_static_texture_image(
image,
memory,
view,
width: texture.width,
height: texture.height,
mip_levels,
})
}
/// Allocates two aligned camera slots used by the dynamic frame UBO.
pub(super) fn create_frame_uniform_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> {
const FRAME_SLOTS: u64 = 2;
const FRAME_BYTES: u64 = super::VULKAN_FRAME_UNIFORM_BYTES as u64;
let limits =
// SAFETY: The physical device belongs to this live instance and the query returns a value copy.
unsafe { instance.instance.get_physical_device_properties(device.physical_device()) };
let alignment = u64::from(limits.limits.min_uniform_buffer_offset_alignment).max(1);
let stride = FRAME_BYTES.checked_add(alignment - 1).ok_or(
VulkanSmokeRendererError::InvalidStaticMesh {
context: "frame uniform stride overflow",
},
)? / alignment
* alignment;
let size =
stride
.checked_mul(FRAME_SLOTS)
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "frame uniform buffer size overflow",
})?;
let bytes = vec![
0_u8;
usize::try_from(size).map_err(|_| {
VulkanSmokeRendererError::InvalidStaticMesh {
context: "frame uniform buffer size exceeds address space",
}
})?
];
let buffer = create_host_visible_buffer(
instance,
device,
&bytes,
vk::BufferUsageFlags::UNIFORM_BUFFER,
"frame uniform buffer",
)?;
Ok((buffer, stride))
}
/// Allocates one aligned storage-buffer record for every material descriptor.
///
/// The descriptor uses a dynamic storage-buffer offset. Keeping the stride
/// aligned to the physical device limit lets the renderer select a material
/// record with the same descriptor set for every draw without baking material
/// indices into the pipeline layout.
pub(super) fn create_material_specular_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
material_count: usize,
) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> {
if material_count == 0 {
return Err(VulkanSmokeRendererError::InvariantViolation {
context: "material specular buffer has no material records",
});
}
let limits =
// SAFETY: The physical device belongs to this live instance and the query returns a value copy.
unsafe { instance.instance.get_physical_device_properties(device.physical_device()) };
let alignment = limits.limits.min_storage_buffer_offset_alignment.max(1);
let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64;
let stride = record_bytes.checked_add(alignment - 1).ok_or(
VulkanSmokeRendererError::InvalidStaticMesh {
context: "material specular stride overflow",
},
)? / alignment
* alignment;
let count =
u64::try_from(material_count).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
context: "material specular count exceeds address space",
})?;
let size = stride
.checked_mul(count)
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "material specular buffer size overflow",
})?;
let bytes =
vec![
0_u8;
usize::try_from(size).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
context: "material specular buffer size exceeds address space",
})?
];
let buffer = create_host_visible_buffer(
instance,
device,
&bytes,
vk::BufferUsageFlags::STORAGE_BUFFER,
"material specular buffer",
)?;
Ok((buffer, stride))
}
/// Replaces all material specular records in one host-coherent upload.
pub(super) fn write_material_specular_buffer(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
stride: u64,
records: &[[[f32; 4]; 2]],
) -> Result<(), VulkanSmokeRendererError> {
if records.is_empty() {
return Err(VulkanSmokeRendererError::InvariantViolation {
context: "material specular upload has no records",
});
}
let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES;
let total_bytes = stride
.checked_mul(u64::try_from(records.len()).unwrap_or(u64::MAX))
.ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "material specular upload size",
})?;
let total_bytes_usize =
usize::try_from(total_bytes).map_err(|_| VulkanSmokeRendererError::InvariantViolation {
context: "material specular upload size exceeds address space",
})?;
let mut bytes = vec![0_u8; total_bytes_usize];
for (record_index, record) in records.iter().enumerate() {
let record_offset = usize::try_from(
stride
.checked_mul(u64::try_from(record_index).unwrap_or(u64::MAX))
.ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "material specular upload offset",
})?,
)
.map_err(|_| VulkanSmokeRendererError::InvariantViolation {
context: "material specular upload offset exceeds address space",
})?;
let mut offset = record_offset;
for vector in record {
for value in vector {
bytes[offset..offset + std::mem::size_of::<f32>()]
.copy_from_slice(&value.to_ne_bytes());
offset += std::mem::size_of::<f32>();
}
}
debug_assert_eq!(offset - record_offset, record_bytes);
}
// SAFETY: The storage buffer is host-visible and the caller has waited for
// all prior uses before replacing its records.
let mapped = unsafe {
device
.device()
.map_memory(buffer.memory, 0, total_bytes, vk::MemoryMapFlags::empty())
}
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
context: "vkMapMemory(material specular buffer)",
result,
})?;
// SAFETY: The mapped range is the complete upload and the destination is
// valid for the exact byte count copied below.
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
device.device().unmap_memory(buffer.memory);
}
Ok(())
}
/// Writes one complete frame block into the slot selected by the
/// fence-safe frame index.
pub(super) fn write_frame_uniform(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
stride: u64,
frame_index: usize,
bytes: &[u8; super::VULKAN_FRAME_UNIFORM_BYTES],
) -> Result<(), VulkanSmokeRendererError> {
let slot =
u64::try_from(frame_index).map_err(|_| VulkanSmokeRendererError::InvariantViolation {
context: "frame uniform slot index",
})?;
let offset = stride
.checked_mul(slot)
.ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "frame uniform slot offset",
})?;
// SAFETY: The offset is one of the two allocated aligned slots and the
// mapped range is exactly one complete frame block.
let mapped = unsafe {
device.device().map_memory(
buffer.memory,
offset,
super::VULKAN_FRAME_UNIFORM_BYTES as u64,
vk::MemoryMapFlags::empty(),
)
}
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
context: "vkMapMemory(frame uniform buffer)",
result,
})?;
// SAFETY: The destination is the mapped frame slot and the byte slice lives
// for this copy.
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
device.device().unmap_memory(buffer.memory);
}
Ok(())
}
fn upload_static_texture(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
@@ -381,6 +721,7 @@ fn upload_static_texture(
image: vk::Image,
width: u32,
height: u32,
mip_levels: u32,
) -> Result<(), VulkanSmokeRendererError> {
let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(command_pool)
@@ -407,7 +748,7 @@ fn upload_static_texture(
result,
});
}
let range = color_subresource_range();
let range = color_subresource_range(mip_levels);
let to_transfer = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
@@ -415,19 +756,34 @@ fn upload_static_texture(
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.image(image)
.subresource_range(range);
let region = vk::BufferImageCopy::default()
let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0));
let mut buffer_offset = 0_u64;
let mut mip_width = width;
let mut mip_height = height;
for mip_level in 0..mip_levels {
let mip_bytes = u64::from(mip_width)
.saturating_mul(u64::from(mip_height))
.saturating_mul(4);
regions.push(
vk::BufferImageCopy::default()
.buffer_offset(buffer_offset)
.image_subresource(
vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.mip_level(0)
.mip_level(mip_level)
.base_array_layer(0)
.layer_count(1),
)
.image_extent(vk::Extent3D {
width,
height,
width: mip_width,
height: mip_height,
depth: 1,
});
}),
);
buffer_offset = buffer_offset.saturating_add(mip_bytes);
mip_width = (mip_width / 2).max(1);
mip_height = (mip_height / 2).max(1);
}
let to_sampled = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
@@ -451,7 +807,7 @@ fn upload_static_texture(
staging_buffer,
image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[region],
&regions,
);
device.device().cmd_pipeline_barrier(
command_buffer,
@@ -500,6 +856,246 @@ fn upload_static_texture(
})
}
/// Replaces the contents of an already sampled texture without changing its
/// image view or descriptor set. The caller must wait for the renderer's
/// submitted work before invoking this function.
pub(super) fn update_static_texture_image(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
image: &VulkanAllocatedImage,
texture: &VulkanStaticTexture,
) -> Result<(), VulkanSmokeRendererError> {
texture
.validate()
.map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?;
let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture);
if texture.width != image.width
|| texture.height != image.height
|| mip_levels != image.mip_levels
{
return Err(VulkanSmokeRendererError::InvalidStaticTexture {
context: "dynamic texture extent or mip count differs from allocation",
});
}
let staging = create_host_visible_buffer(
instance,
device,
&mip_bytes,
vk::BufferUsageFlags::TRANSFER_SRC,
"dynamic texture staging buffer",
)?;
let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(command_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
// SAFETY: The command pool is live and owned by the current logical device.
let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) }
.map_err(|result| {
destroy_allocated_buffer(device, &staging);
VulkanSmokeRendererError::VulkanOperation {
context: "vkAllocateCommandBuffers(dynamic texture upload)",
result,
}
})?[0];
let begin =
vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
// SAFETY: The command buffer is freshly allocated from the current pool.
if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } {
// SAFETY: The command buffer belongs to this pool and is released on failure.
unsafe {
device
.device()
.free_command_buffers(command_pool, &[command_buffer]);
};
destroy_allocated_buffer(device, &staging);
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkBeginCommandBuffer(dynamic texture upload)",
result,
});
}
let range = color_subresource_range(mip_levels);
let to_transfer = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.src_access_mask(vk::AccessFlags::SHADER_READ)
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.image(image.image)
.subresource_range(range);
let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0));
let mut buffer_offset = 0_u64;
let mut mip_width = texture.width;
let mut mip_height = texture.height;
for mip_level in 0..mip_levels {
let mip_bytes = u64::from(mip_width)
.saturating_mul(u64::from(mip_height))
.saturating_mul(4);
regions.push(
vk::BufferImageCopy::default()
.buffer_offset(buffer_offset)
.image_subresource(
vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.mip_level(mip_level)
.base_array_layer(0)
.layer_count(1),
)
.image_extent(vk::Extent3D {
width: mip_width,
height: mip_height,
depth: 1,
}),
);
buffer_offset = buffer_offset.saturating_add(mip_bytes);
mip_width = (mip_width / 2).max(1);
mip_height = (mip_height / 2).max(1);
}
let to_sampled = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.dst_access_mask(vk::AccessFlags::SHADER_READ)
.image(image.image)
.subresource_range(range);
// SAFETY: The caller has made all prior shader reads idle, and the image
// remains exclusively owned by this graphics queue.
unsafe {
device.device().cmd_pipeline_barrier(
command_buffer,
vk::PipelineStageFlags::FRAGMENT_SHADER,
vk::PipelineStageFlags::TRANSFER,
vk::DependencyFlags::empty(),
&[],
&[],
&[to_transfer],
);
device.device().cmd_copy_buffer_to_image(
command_buffer,
staging.buffer,
image.image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&regions,
);
device.device().cmd_pipeline_barrier(
command_buffer,
vk::PipelineStageFlags::TRANSFER,
vk::PipelineStageFlags::FRAGMENT_SHADER,
vk::DependencyFlags::empty(),
&[],
&[],
&[to_sampled],
);
}
// SAFETY: Command recording is complete on the current command buffer.
if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } {
// SAFETY: The command buffer belongs to this pool and is released on failure.
unsafe {
device
.device()
.free_command_buffers(command_pool, &[command_buffer]);
};
destroy_allocated_buffer(device, &staging);
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkEndCommandBuffer(dynamic texture upload)",
result,
});
}
let command_buffers = [command_buffer];
let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)];
// SAFETY: The graphics queue and submitted command buffer remain live
// until this synchronous wait completes.
let submit_result = unsafe {
device
.device()
.queue_submit(device.graphics_queue(), &submit, vk::Fence::null())
};
let result = submit_result
.and_then(|()| unsafe { device.device().queue_wait_idle(device.graphics_queue()) });
// SAFETY: Submission completed or failed synchronously.
unsafe {
device
.device()
.free_command_buffers(command_pool, &[command_buffer]);
}
destroy_allocated_buffer(device, &staging);
result.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
context: "vkQueueSubmit/WaitIdle(dynamic texture upload)",
result,
})
}
/// Builds an RGBA8 mip chain on the CPU so texture upload does not depend on
/// optional linear-blit support for the device's UNORM format.
fn build_static_texture_mip_chain(texture: &VulkanStaticTexture) -> (Vec<u8>, u32) {
if !texture.mip_levels.is_empty() {
let mut bytes = Vec::new();
for mip in &texture.mip_levels {
bytes.extend_from_slice(&mip.rgba8);
}
return (
bytes,
u32::try_from(texture.mip_levels.len()).unwrap_or(u32::MAX),
);
}
let mut bytes = texture.rgba8.clone();
let mut level = texture.rgba8.clone();
let mut width = texture.width;
let mut height = texture.height;
let mut mip_levels = 1_u32;
while width > 1 || height > 1 {
let next_width = (width / 2).max(1);
let next_height = (height / 2).max(1);
let mut next = vec![
0_u8;
usize::try_from(next_width)
.unwrap_or(0)
.saturating_mul(usize::try_from(next_height).unwrap_or(0))
.saturating_mul(4)
];
for y in 0..next_height {
for x in 0..next_width {
let dst = (usize::try_from(y).unwrap_or(0)
* usize::try_from(next_width).unwrap_or(0)
+ usize::try_from(x).unwrap_or(0))
* 4;
let x0 = x.saturating_mul(2).min(width.saturating_sub(1));
let y0 = y.saturating_mul(2).min(height.saturating_sub(1));
let x1 = x0.saturating_add(1).min(width.saturating_sub(1));
let y1 = y0.saturating_add(1).min(height.saturating_sub(1));
let source_width = usize::try_from(width).unwrap_or(0);
let samples = [
(usize::try_from(y0).unwrap_or(0) * source_width
+ usize::try_from(x0).unwrap_or(0))
* 4,
(usize::try_from(y0).unwrap_or(0) * source_width
+ usize::try_from(x1).unwrap_or(0))
* 4,
(usize::try_from(y1).unwrap_or(0) * source_width
+ usize::try_from(x0).unwrap_or(0))
* 4,
(usize::try_from(y1).unwrap_or(0) * source_width
+ usize::try_from(x1).unwrap_or(0))
* 4,
];
for channel in 0..4 {
let sum: u16 = samples
.iter()
.map(|&offset| u16::from(level[offset + channel]))
.sum();
next[dst + channel] = u8::try_from((sum + 2) / 4).unwrap_or(u8::MAX);
}
}
}
bytes.extend_from_slice(&next);
level = next;
width = next_width;
height = next_height;
mip_levels = mip_levels.saturating_add(1);
}
(bytes, mip_levels)
}
pub(super) fn destroy_allocated_image(
device: &VulkanLogicalDeviceProbe,
image: &VulkanAllocatedImage,
@@ -624,36 +1220,24 @@ pub(super) fn create_frame_sync(
let mut sync = Vec::with_capacity(2);
for _ in 0..2 {
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
let image_available = unsafe { device.device().create_semaphore(&semaphore_info, None) }
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSemaphore(image_available)",
result: error,
})?;
let render_finished = {
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
let image_available =
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
Ok(render_finished) => render_finished,
Ok(image_available) => image_available,
Err(error) => {
destroy_frame_sync_objects(device, &sync);
// SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_semaphore(image_available, None) };
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSemaphore(render_finished)",
context: "vkCreateSemaphore(image_available)",
result: error,
});
}
}
};
// SAFETY: The fence belongs to this live logical device and is destroyed at teardown.
let fence = match unsafe { device.device().create_fence(&fence_info, None) } {
Ok(fence) => fence,
Err(error) => {
destroy_frame_sync_objects(device, &sync);
// SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure.
unsafe {
device.device().destroy_semaphore(image_available, None);
device.device().destroy_semaphore(render_finished, None);
}
// SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_semaphore(image_available, None) };
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateFence",
result: error,
@@ -662,7 +1246,6 @@ pub(super) fn create_frame_sync(
};
sync.push(VulkanFrameSync {
image_available,
render_finished,
fence,
});
}
@@ -676,9 +1259,6 @@ fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanF
device
.device()
.destroy_semaphore(frame_sync.image_available, None);
device
.device()
.destroy_semaphore(frame_sync.render_finished, None);
device.device().destroy_fence(frame_sync.fence, None);
}
}
@@ -738,11 +1318,11 @@ pub(super) fn readback_buffer_bytes(
Ok(bytes)
}
pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange {
pub(super) fn color_subresource_range(level_count: u32) -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.base_mip_level(0)
.level_count(1)
.level_count(level_count)
.base_array_layer(0)
.layer_count(1)
}
File diff suppressed because it is too large Load Diff
@@ -48,8 +48,20 @@ pub struct VulkanStaticVertex {
pub position: [f32; 3],
/// Linear RGB vertex color.
pub color: [f32; 3],
/// Unit vertex normal in world/object coordinates.
pub normal: [f32; 3],
/// Texture coordinate consumed by the static material bridge.
pub uv: [f32; 2],
/// Repeating detail texture coordinate from terrain type-18 UV data.
///
/// Non-terrain geometry supplies `[0.0; 2]`; its neutral detail texture
/// keeps the additional stage mathematically transparent.
pub detail_uv: [f32; 2],
/// Interpolated terrain overlay mask from type-14 data.
///
/// Non-terrain geometry supplies `0.0`, disabling the transparent overlay
/// slot while retaining one vertex contract for every static draw.
pub overlay_alpha: f32,
}
/// A static geometry camera represented in the shader's matrix memory order.
@@ -71,7 +83,9 @@ impl VulkanStaticCamera {
projection: LegacyD3d7Projection,
) -> Option<Self> {
let view = transform.try_direct3d7_view_row_major()?;
let projection = projection.try_direct3d7_projection_row_major()?;
let mut projection = projection.try_direct3d7_projection_row_major()?;
// D3D7's top-down viewport and Vulkan's positive-height viewport invert NDC Y.
projection[5] = -projection[5];
Self::from_row_major_view_projection(view, projection)
}
@@ -108,6 +122,242 @@ impl Default for VulkanStaticCamera {
}
}
/// Number of directional records carried by [`VulkanFrameUniforms`].
pub const VULKAN_DIRECTIONAL_LIGHT_COUNT: usize = 4;
/// One directional light in the frame lighting contract.
///
/// The direction is the vector from the light toward the surface. The
/// coefficients are retained in the same three-component record as the
/// native point callback so frame construction can copy the original light
/// table without a lossy conversion.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanDirectionalLight {
/// Direction from the light toward the surface.
pub direction: [f32; 3],
/// Light RGB multiplier.
pub rgb: [f32; 3],
/// Native light coefficients. Directional callbacks normally use the
/// neutral `[0, 0, 1]` record; the field keeps the shared light ABI.
pub coefficients: [f32; 3],
/// Whether the light participates in this frame.
pub active: bool,
}
impl Default for VulkanDirectionalLight {
fn default() -> Self {
Self {
direction: [0.0, 0.0, 1.0],
rgb: [0.0; 3],
coefficients: [0.0, 0.0, 1.0],
active: false,
}
}
}
/// One point light in the frame lighting contract.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanPointLight {
/// World-space position.
pub position: [f32; 3],
/// Light RGB multiplier.
pub rgb: [f32; 3],
/// Radius used by native normalized distance attenuation.
pub range: f32,
/// Native attenuation coefficients `a0`, `a1`, and `a2`.
pub coefficients: [f32; 3],
/// Whether the light participates in this frame.
pub active: bool,
}
impl Default for VulkanPointLight {
fn default() -> Self {
Self {
position: [0.0; 3],
rgb: [0.0; 3],
range: 0.0,
// A neutral record is explicit. In particular, this is the
// proven native environment contract and avoids an accidental
// zero polynomial when a caller enables the point slot.
coefficients: [0.0, 0.0, 1.0],
active: false,
}
}
}
/// Number of bytes occupied by one frame-uniform slot.
///
/// The block is deliberately made entirely from `mat4`/`vec4`-sized records
/// so its CPU representation follows Vulkan's `std140` alignment rules on
/// every device. Four directional lights and one point light fit in a small
/// dynamic UBO while the push-constant range remains available for material
/// state.
pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368;
/// Bytes occupied by one material's base and overlay specular records.
///
/// Each record is one `vec4`: RGB followed by the native integer power stored
/// as an IEEE-754 float for direct shader use. The allocation stride is
/// rounded up to the device's `minStorageBufferOffsetAlignment`.
pub const VULKAN_MATERIAL_SPECULAR_RECORD_BYTES: usize = 2 * 4 * std::mem::size_of::<f32>();
/// Per-frame data shared by the vertex and fragment stages.
///
/// `clip_from_world` is row-major D3D-style storage for the same reason as
/// [`VulkanStaticCamera`]. A directional light direction points from the
/// light toward the surface; the shader negates it when taking the
/// surface-normal dot product. Fog distances are disabled when
/// `fog_end <= fog_start`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanFrameUniforms {
/// Row-major clip-from-world transform.
pub clip_from_world: [f32; 16],
/// Up to four native directional/celestial lights.
pub directional_lights: [VulkanDirectionalLight; VULKAN_DIRECTIONAL_LIGHT_COUNT],
/// Native point light slot.
pub point_light: VulkanPointLight,
/// Per-channel lighting floor applied before native range compression.
pub lighting_floor: [f32; 3],
/// Linear fog RGB color.
pub fog_color: [f32; 3],
/// Fog start distance.
pub fog_start: f32,
/// Fog end distance.
pub fog_end: f32,
/// World-space camera position used for fog distance.
pub camera_position: [f32; 3],
}
impl VulkanFrameUniforms {
/// Builds neutral frame state from a static camera.
#[must_use]
pub fn from_camera(camera: VulkanStaticCamera) -> Self {
let mut directional_lights =
[VulkanDirectionalLight::default(); VULKAN_DIRECTIONAL_LIGHT_COUNT];
directional_lights[0] = VulkanDirectionalLight {
direction: [0.35, 0.45, 0.82],
rgb: [1.0; 3],
coefficients: [0.0, 0.0, 1.0],
active: true,
};
Self {
clip_from_world: camera.clip_from_world,
directional_lights,
point_light: VulkanPointLight::default(),
lighting_floor: [0.30; 3],
fog_color: [0.0; 3],
fog_start: f32::MAX,
fog_end: f32::MAX,
camera_position: [0.0; 3],
}
}
/// Returns whether all submitted frame values are finite.
#[must_use]
pub fn is_finite(self) -> bool {
self.clip_from_world
.into_iter()
.chain(self.directional_lights.into_iter().flat_map(|light| {
light
.direction
.into_iter()
.chain(light.rgb)
.chain(light.coefficients)
.chain([if light.active { 1.0 } else { 0.0 }])
}))
.chain(self.point_light.position)
.chain(self.point_light.rgb)
.chain([self.point_light.range])
.chain(self.point_light.coefficients)
.chain([if self.point_light.active { 1.0 } else { 0.0 }])
.chain(self.lighting_floor)
.chain(self.fog_color)
.chain([self.fog_start, self.fog_end])
.chain(self.camera_position)
.all(f32::is_finite)
}
/// Serializes one frame slot in the exact `std140` member order.
#[must_use]
pub fn to_ne_bytes(self) -> [u8; VULKAN_FRAME_UNIFORM_BYTES] {
let mut values = [0.0_f32; VULKAN_FRAME_UNIFORM_BYTES / 4];
values[..16].copy_from_slice(&self.clip_from_world);
for (index, light) in self.directional_lights.into_iter().enumerate() {
let direction_offset = 16 + index * 4;
values[direction_offset..direction_offset + 4].copy_from_slice(&[
light.direction[0],
light.direction[1],
light.direction[2],
if light.active { 1.0 } else { 0.0 },
]);
let rgb_offset = 32 + index * 4;
values[rgb_offset..rgb_offset + 4].copy_from_slice(&[
light.rgb[0],
light.rgb[1],
light.rgb[2],
if light.active { 1.0 } else { 0.0 },
]);
let coefficient_offset = 48 + index * 4;
values[coefficient_offset..coefficient_offset + 4].copy_from_slice(&[
light.coefficients[0],
light.coefficients[1],
light.coefficients[2],
if light.active { 1.0 } else { 0.0 },
]);
}
values[64..68].copy_from_slice(&[
self.point_light.position[0],
self.point_light.position[1],
self.point_light.position[2],
self.point_light.range,
]);
values[68..72].copy_from_slice(&[
self.point_light.rgb[0],
self.point_light.rgb[1],
self.point_light.rgb[2],
if self.point_light.active { 1.0 } else { 0.0 },
]);
values[72..76].copy_from_slice(&[
self.point_light.coefficients[0],
self.point_light.coefficients[1],
self.point_light.coefficients[2],
if self.point_light.active { 1.0 } else { 0.0 },
]);
values[76..80].copy_from_slice(&[
self.lighting_floor[0],
self.lighting_floor[1],
self.lighting_floor[2],
0.0,
]);
values[80..84].copy_from_slice(&[
self.fog_color[0],
self.fog_color[1],
self.fog_color[2],
0.0,
]);
values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]);
values[88..92].copy_from_slice(&[
self.camera_position[0],
self.camera_position[1],
self.camera_position[2],
0.0,
]);
let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES];
for (index, value) in values.into_iter().enumerate() {
let offset = index * std::mem::size_of::<f32>();
bytes[offset..offset + std::mem::size_of::<f32>()]
.copy_from_slice(&value.to_ne_bytes());
}
bytes
}
}
impl Default for VulkanFrameUniforms {
fn default() -> Self {
Self::from_camera(VulkanStaticCamera::default())
}
}
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
@@ -131,6 +381,23 @@ pub struct VulkanStaticMesh {
pub draw_ranges: Vec<VulkanStaticDrawRange>,
}
/// Geometry-only changes applied to one retained draw range.
///
/// The renderer owns the material, blend/depth state, active count changes,
/// and queue classification for every range. A frame builder uses this
/// record only when a variable-size geometry stream (such as projected
/// shadows) moves its index segment or changes its triangle count.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanDynamicDrawRange {
/// Stable draw-range handle returned by the initial mesh layout.
pub range_index: usize,
/// First index in the current shared index upload.
pub first_index: u32,
/// Active triangle-list index count. Zero disables this range for the
/// current frame without allocating a zero-sized Vulkan buffer.
pub index_count: u32,
}
/// One indexed triangle-list draw retained from an original mesh batch.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanStaticDrawRange {
@@ -140,6 +407,12 @@ pub struct VulkanStaticDrawRange {
pub index_count: u32,
/// Original positional `Batch20.material_index` selector.
pub material_index: u16,
/// Original positional `Batch20.lightmap_index` selector.
pub lightmap_index: u8,
/// Original MSH Batch20 flag word used by the native transparent queue.
/// Bits `0x100` and `0x008` opt a range into that queue independently of
/// the material blend category.
pub batch_flags: u16,
/// Backend-neutral fixed-function state for this source range.
pub pipeline_state: LegacyPipelineState,
/// Alpha-test reference in legacy 0..=255 units; dynamic material data.
@@ -147,6 +420,13 @@ pub struct VulkanStaticDrawRange {
}
impl VulkanStaticDrawRange {
/// The value installed by the native renderer before a material-specific
/// alpha reference is written. The native Ngi32 initialiser stores one
/// in `D3DRS_ALPHAREF`; callers that do not have a material-specific value
/// should pass this constant explicitly. Zero remains a valid explicit
/// alpha reference.
pub const NATIVE_DEFAULT_ALPHA_TEST_REFERENCE: u8 = 1;
/// Returns the canonical key used for Vulkan pipeline selection.
#[must_use]
pub fn pipeline_key(self) -> PipelineKey {
@@ -165,12 +445,75 @@ impl VulkanStaticDrawRange {
}
/// One diffuse material texture keyed by an original MSH batch selector.
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct VulkanStaticMaterial {
/// Positional material selector used by one or more source batches.
pub material_index: u16,
/// Decoded RGBA8 diffuse texture for this selector.
pub texture: VulkanStaticTexture,
/// Phase parameters 4..6, normalized directional light RGB coefficient.
pub directional_rgb: [f32; 3],
/// Phase parameters 0..2, normalized additive RGB coefficient.
pub additive_rgb: [f32; 3],
/// Normalized atlas transform for base texture `[x, y, w, h]`.
pub uv_transform: [f32; 4],
/// Optional Land2 detail texture paired with the base texture.
pub detail_texture: Option<VulkanStaticTexture>,
/// Optional Land1 overlay texture selected by the face tag high byte.
pub overlay_texture: Option<VulkanStaticTexture>,
/// Optional Land2 detail texture paired with the overlay texture.
pub overlay_detail_texture: Option<VulkanStaticTexture>,
/// MAT0 phase diffuse alpha, normalized from the legacy 0..=255 byte.
pub diffuse_alpha: f32,
/// MAT0 phase alpha for the optional overlay material.
pub overlay_diffuse_alpha: Option<f32>,
/// Phase parameters 4..6 for the optional overlay material.
pub overlay_directional_rgb: Option<[f32; 3]>,
/// Phase parameters 0..2 for the optional overlay material.
pub overlay_additive_rgb: Option<[f32; 3]>,
/// Phase parameters 8..10, normalized base specular RGB coefficient.
///
/// Specular data is kept in a storage buffer instead of push constants so
/// the vertex stage can evaluate the native Gouraud highlight while the
/// material's other dynamic values retain their existing ABI.
pub specular_rgb: [f32; 3],
/// Phase parameter 16 for the base material, carried as the native byte.
pub specular_power: u8,
/// Phase parameters 8..10 for the optional overlay material.
pub overlay_specular_rgb: Option<[f32; 3]>,
/// Phase parameter 16 for the optional overlay material.
pub overlay_specular_power: Option<u8>,
/// Normalized atlas transform for the optional detail texture.
pub detail_uv_transform: [f32; 4],
/// Normalized atlas transform for the optional overlay texture.
pub overlay_uv_transform: Option<[f32; 4]>,
/// Normalized atlas transform for the optional overlay detail texture.
pub overlay_detail_uv_transform: Option<[f32; 4]>,
/// Environment layers bypass terrain lighting and fog while retaining the
/// same descriptor/pipeline path.
pub unlit: bool,
/// Native sky mode 4: blend base and secondary textures by the secondary
/// texture alpha before multiplying by the vertex diffuse color.
pub sky_nebula_stars: bool,
/// Native terrain mode 3: combine base and secondary lightmap textures
/// without the regular terrain `2 * TEX0 * TEX1` combiner or NdotL.
pub lightmap_mode: bool,
/// Push material mode bit 4 and map this sky layer to the Vulkan far
/// plane without changing its world-space vertices or UVs. This is a
/// renderer projection choice; it is not a claim about the original D3D
/// depth-bit encoding.
pub sky_far_depth: bool,
}
/// One authored RGBA8 mip level accepted by the Vulkan texture upload path.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanStaticTextureMip {
/// Mip width in texels.
pub width: u32,
/// Mip height in texels.
pub height: u32,
/// Row-major RGBA8 pixels.
pub rgba8: Vec<u8>,
}
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
@@ -182,6 +525,10 @@ pub struct VulkanStaticTexture {
pub height: u32,
/// Row-major RGBA8 pixels.
pub rgba8: Vec<u8>,
/// Authored mip levels, including mip zero, when available.
///
/// An empty vector requests deterministic CPU generation from `rgba8`.
pub mip_levels: Vec<VulkanStaticTextureMip>,
}
impl VulkanStaticTexture {
@@ -200,7 +547,46 @@ impl VulkanStaticTexture {
Some(expected) if expected != self.rgba8.len() => {
Err("static texture rgba8 byte count does not match extent")
}
Some(_) => Ok(()),
Some(_) => {
if self.mip_levels.is_empty() {
return Ok(());
}
// TEXM files commonly stop at the authored 4x4 level. Vulkan
// permits a partial mip chain; only the supplied level
// extents and bytes need validation.
if self.mip_levels.len()
> usize::try_from(self.width.max(self.height))
.unwrap_or(usize::MAX)
.ilog2() as usize
+ 1
{
return Err("static texture authored mip count exceeds extent");
}
let mut width = self.width;
let mut height = self.height;
for (level, mip) in self.mip_levels.iter().enumerate() {
if mip.width != width || mip.height != height {
return Err("static texture authored mip extent does not match level");
}
let expected = usize::try_from(width)
.ok()
.and_then(|width| {
usize::try_from(height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.and_then(|pixels| pixels.checked_mul(4));
if expected != Some(mip.rgba8.len()) {
return Err("static texture authored mip byte count does not match extent");
}
if level == 0 && mip.rgba8 != self.rgba8 {
return Err("static texture authored mip zero does not match rgba8");
}
width = (width / 2).max(1);
height = (height / 2).max(1);
}
Ok(())
}
}
}
}
@@ -216,6 +602,60 @@ pub(super) fn resolve_draw_texture_indices(
) -> Result<Vec<usize>, &'static str> {
for (index, material) in materials.iter().enumerate() {
material.texture.validate()?;
if let Some(texture) = &material.detail_texture {
texture.validate()?;
}
if let Some(texture) = &material.overlay_texture {
texture.validate()?;
}
if let Some(texture) = &material.overlay_detail_texture {
texture.validate()?;
}
if !material.diffuse_alpha.is_finite() || !(0.0..=1.0).contains(&material.diffuse_alpha) {
return Err("static material diffuse alpha must be finite and 0..1");
}
if material
.directional_rgb
.into_iter()
.chain(material.additive_rgb)
.chain(material.specular_rgb)
.chain(material.overlay_specular_rgb.into_iter().flatten())
.any(|value| !value.is_finite() || value < 0.0)
{
return Err("static material lighting and specular RGB parameters must be finite and non-negative");
}
if material
.uv_transform
.into_iter()
.chain(material.detail_uv_transform)
.any(|value| !value.is_finite())
{
return Err("static material UV transforms must be finite");
}
if material
.overlay_diffuse_alpha
.is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha))
{
return Err("static material overlay diffuse alpha must be finite and 0..1");
}
if material
.overlay_directional_rgb
.into_iter()
.flatten()
.chain(material.overlay_additive_rgb.into_iter().flatten())
.any(|value| !value.is_finite() || value < 0.0)
{
return Err("static material overlay RGB parameters must be finite and non-negative");
}
if material
.overlay_uv_transform
.into_iter()
.flatten()
.chain(material.overlay_detail_uv_transform.into_iter().flatten())
.any(|value| !value.is_finite())
{
return Err("static material overlay UV transforms must be finite");
}
if materials[..index]
.iter()
.any(|previous| previous.material_index == material.material_index)
@@ -247,17 +687,26 @@ impl VulkanStaticMesh {
VulkanStaticVertex {
position: [0.0, -0.55, 0.0],
color: [1.0, 0.2, 0.2],
normal: [0.0, 0.0, 1.0],
uv: [0.5, 0.0],
detail_uv: [0.0, 0.0],
overlay_alpha: 0.0,
},
VulkanStaticVertex {
position: [0.55, 0.55, 0.0],
color: [0.2, 1.0, 0.2],
normal: [0.0, 0.0, 1.0],
uv: [1.0, 1.0],
detail_uv: [0.0, 0.0],
overlay_alpha: 0.0,
},
VulkanStaticVertex {
position: [-0.55, 0.55, 0.0],
color: [0.2, 0.4, 1.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 1.0],
detail_uv: [0.0, 0.0],
overlay_alpha: 0.0,
},
],
indices: vec![0, 1, 2],
@@ -265,6 +714,8 @@ impl VulkanStaticMesh {
first_index: 0,
index_count: 3,
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
}],
@@ -275,6 +726,22 @@ impl VulkanStaticMesh {
if self.vertices.is_empty() {
return Err("static mesh has no vertices");
}
if self.vertices.iter().any(|vertex| {
!vertex
.position
.iter()
.chain(vertex.color.iter())
.chain(vertex.normal.iter())
.chain(vertex.uv.iter())
.chain(vertex.detail_uv.iter())
.chain(std::iter::once(&vertex.overlay_alpha))
.all(|value| value.is_finite())
|| !(0.0..=1.0).contains(&vertex.overlay_alpha)
}) {
return Err(
"static mesh vertex attributes must be finite and overlay alpha must be 0..1",
);
}
if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) {
return Err("static mesh indices must contain complete triangles");
}
@@ -311,6 +778,53 @@ impl VulkanStaticMesh {
mod static_mesh_tests {
use super::*;
#[test]
fn frame_uniforms_use_std140_sized_slots_and_preserve_dynamic_values() {
let frame = VulkanFrameUniforms {
clip_from_world: std::array::from_fn(|index| index as f32),
directional_lights: [
VulkanDirectionalLight {
direction: [1.0, 2.0, 3.0],
rgb: [4.0, 5.0, 6.0],
coefficients: [7.0, 8.0, 9.0],
active: true,
},
VulkanDirectionalLight::default(),
VulkanDirectionalLight::default(),
VulkanDirectionalLight::default(),
],
point_light: VulkanPointLight {
position: [28.0, 29.0, 30.0],
rgb: [31.0, 32.0, 33.0],
range: 34.0,
coefficients: [35.0, 36.0, 37.0],
active: true,
},
lighting_floor: [7.0, 8.0, 9.0],
fog_color: [10.0, 11.0, 12.0],
fog_start: 13.0,
fog_end: 14.0,
camera_position: [15.0, 16.0, 17.0],
};
let bytes = frame.to_ne_bytes();
assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES);
let read =
|offset: usize| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().expect("f32"));
assert_eq!(read(0), 0.0);
assert_eq!(read(60), 15.0);
assert_eq!(read(64), 1.0);
assert_eq!(read(76), 1.0);
assert_eq!(read(128), 4.0);
assert_eq!(read(192), 7.0);
assert_eq!(read(256), 28.0);
assert_eq!(read(272), 31.0);
assert_eq!(read(288), 35.0);
assert_eq!(read(304), 7.0);
assert_eq!(read(320), 10.0);
assert_eq!(read(336), 13.0);
assert_eq!(read(352), 15.0);
}
#[test]
fn smoke_triangle_is_valid_complete_geometry() {
let mesh = VulkanStaticMesh::smoke_triangle();
@@ -357,6 +871,54 @@ mod static_mesh_tests {
assert_eq!(camera.clip_from_world[7], 0.65_f32.sin());
}
#[test]
fn legacy_camera_maps_world_up_to_top_of_positive_height_vulkan_viewport() {
let transform = RawCameraTransform {
words: [
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1024, 768],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)
.expect("recovered camera inputs are valid");
// The raw identity camera's recovered view maps world +X to depth
// and world +Z to camera up.
let world_up_at_positive_depth = [10.0, 0.0, 1.0, 1.0];
let clip_y = (0..4)
.map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 1])
.sum::<f32>();
let clip_w = (0..4)
.map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 3])
.sum::<f32>();
assert!(clip_w > 0.0);
assert!(
clip_y / clip_w < 0.0,
"world up must map to the viewport top"
);
}
#[test]
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
let identity = VulkanStaticCamera::default().clip_from_world;
@@ -387,6 +949,8 @@ mod static_mesh_tests {
first_index: 0,
index_count: 2,
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
}],
@@ -415,6 +979,8 @@ mod static_mesh_tests {
first_index: 0,
index_count: 3,
material_index: 7,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
},
@@ -422,6 +988,8 @@ mod static_mesh_tests {
first_index: 3,
index_count: 3,
material_index: 2,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
},
@@ -430,15 +998,58 @@ mod static_mesh_tests {
width: 1,
height: 1,
rgba8: vec![255; 4],
mip_levels: Vec::new(),
};
let materials = [
VulkanStaticMaterial {
material_index: 2,
texture: texture(),
detail_texture: None,
overlay_texture: None,
overlay_detail_texture: None,
diffuse_alpha: 1.0,
overlay_diffuse_alpha: None,
directional_rgb: [1.0; 3],
additive_rgb: [0.0; 3],
specular_rgb: [0.0; 3],
specular_power: 0,
overlay_specular_rgb: None,
overlay_specular_power: None,
uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_directional_rgb: None,
overlay_additive_rgb: None,
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_uv_transform: None,
overlay_detail_uv_transform: None,
unlit: false,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
},
VulkanStaticMaterial {
material_index: 7,
texture: texture(),
detail_texture: None,
overlay_texture: None,
overlay_detail_texture: None,
diffuse_alpha: 1.0,
overlay_diffuse_alpha: None,
directional_rgb: [1.0; 3],
additive_rgb: [0.0; 3],
specular_rgb: [0.0; 3],
specular_power: 0,
overlay_specular_rgb: None,
overlay_specular_power: None,
uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_directional_rgb: None,
overlay_additive_rgb: None,
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_uv_transform: None,
overlay_detail_uv_transform: None,
unlit: false,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
},
];
@@ -480,6 +1091,24 @@ mod static_mesh_tests {
};
assert_eq!(disabled.alpha_test_cutoff(), 0.0);
assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0);
let explicit_zero = VulkanStaticDrawRange {
pipeline_state: LegacyPipelineState {
alpha_test: true,
..LegacyPipelineState::default()
},
alpha_test_reference: 0,
..base
};
assert_eq!(explicit_zero.alpha_test_cutoff(), 0.0);
let native_default = VulkanStaticDrawRange {
pipeline_state: LegacyPipelineState {
alpha_test: true,
..LegacyPipelineState::default()
},
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
..base
};
assert_eq!(native_default.alpha_test_cutoff(), 1.0 / 255.0);
}
}
@@ -727,10 +1356,46 @@ pub struct VulkanSmokeRenderer {
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
/// Positional material slots mapped to the owned image list. Fallback
/// stages may share one image without coupling independently animated
/// material textures.
pub(super) texture_slot_indices: Vec<usize>,
pub(super) textures: Vec<VulkanAllocatedImage>,
pub(super) vertex_count: usize,
/// Retained index allocation length. The upload path grows this only when
/// a frame actually needs more shared indices and never shrinks it.
pub(super) index_capacity: usize,
pub(super) draw_range_capacities: Vec<u32>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>,
/// Stable range handles used when recording the native pass order.
pub(super) draw_order: Vec<usize>,
/// Native camera-sort values returned by the draw-item camera callback.
pub(super) draw_range_sort_keys: Vec<Option<f32>>,
/// Whether the stable order must be rebuilt before the next command
/// buffer. Range setters are called in batches by the game loop.
pub(super) draw_order_dirty: bool,
/// World queue classification for each stable range handle. `None` keeps
/// a fixed screen/sky/weather pass; `Some(false)` is world opaque and
/// `Some(true)` is world transparent.
pub(super) draw_range_world_queue: Vec<Option<bool>>,
/// Pipeline state before transparent queue depth policy is applied.
pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>,
pub(super) material_alphas: Vec<[f32; 2]>,
pub(super) material_lighting: Vec<[[f32; 3]; 4]>,
pub(super) material_uv_transforms: Vec<[[f32; 4]; 4]>,
/// Base and overlay specular records uploaded to the dynamic SSBO.
pub(super) material_specular: Vec<[[f32; 4]; 2]>,
/// Original selector for each positional material record.
pub(super) material_selectors: Vec<u16>,
/// Whether the host-side specular records need a fence-safe GPU upload.
pub(super) material_specular_dirty: bool,
/// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
/// sky nebula/stars texture composite, 3 native lightmap composite.
pub(super) material_unlit: Vec<f32>,
pub(super) readback_enabled: bool,
pub(super) camera: VulkanStaticCamera,
pub(super) frame_uniforms: VulkanFrameUniforms,
pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize,
@@ -8,38 +8,85 @@ use fparkan_render::{
use std::collections::BTreeMap;
use super::{
color_subresource_range, create_depth_attachment, destroy_depth_attachment,
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe,
VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
color_subresource_range, create_depth_attachment, create_material_specular_buffer,
destroy_depth_attachment, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment,
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES,
VULKAN_MATERIAL_SPECULAR_RECORD_BYTES,
};
pub(super) struct VulkanSwapchainResources {
pub(super) image_views: Vec<vk::ImageView>,
pub(super) images: Vec<vk::Image>,
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
pub(super) depth_attachment: VulkanDepthAttachment,
/// One depth image per swapchain image so frames can overlap safely.
pub(super) depth_attachments: Vec<VulkanDepthAttachment>,
/// A render-finished semaphore dedicated to each swapchain image.
pub(super) render_finished: Vec<vk::Semaphore>,
pub(super) render_pass: vk::RenderPass,
pub(super) pipeline_layout: vk::PipelineLayout,
pub(super) descriptor_set_layout: vk::DescriptorSetLayout,
pub(super) descriptor_pool: vk::DescriptorPool,
pub(super) descriptor_sets: Vec<vk::DescriptorSet>,
pub(super) sampler: vk::Sampler,
pub(super) frame_uniform_buffer: VulkanAllocatedBuffer,
pub(super) frame_uniform_stride: u64,
pub(super) material_specular_buffer: VulkanAllocatedBuffer,
pub(super) material_specular_stride: u64,
/// Live graphics pipelines indexed by canonical backend-neutral state.
pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
pub(super) framebuffers: Vec<vk::Framebuffer>,
pub(super) command_buffers: Vec<vk::CommandBuffer>,
}
pub(super) fn update_material_texture_descriptor(
device: &VulkanLogicalDeviceProbe,
resources: &VulkanSwapchainResources,
material_index: usize,
texture_slot: usize,
texture: &VulkanAllocatedImage,
) -> Result<(), VulkanSmokeRendererError> {
if texture_slot >= 4 {
return Err(VulkanSmokeRendererError::InvalidStaticTexture {
context: "dynamic material texture slot is out of bounds",
});
}
let descriptor_set = resources
.descriptor_sets
.get(material_index)
.copied()
.ok_or(VulkanSmokeRendererError::InvalidStaticTexture {
context: "dynamic material descriptor set is out of bounds",
})?;
let image_info = vk::DescriptorImageInfo::default()
.sampler(resources.sampler)
.image_view(texture.view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
let write = vk::WriteDescriptorSet::default()
.dst_set(descriptor_set)
.dst_binding(u32::try_from(texture_slot).unwrap_or(u32::MAX))
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(std::slice::from_ref(&image_info));
// SAFETY: the renderer idles the device before changing a descriptor that
// may be referenced by an in-flight command buffer.
unsafe { device.device().update_descriptor_sets(&[write], &[]) };
Ok(())
}
struct PartialSwapchainResources {
image_views: Vec<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
depth_attachment: Option<VulkanDepthAttachment>,
depth_attachments: Vec<VulkanDepthAttachment>,
render_finished: Vec<vk::Semaphore>,
render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>,
descriptor_set_layout: Option<vk::DescriptorSetLayout>,
descriptor_pool: Option<vk::DescriptorPool>,
sampler: Option<vk::Sampler>,
frame_uniform_buffer: Option<VulkanAllocatedBuffer>,
frame_uniform_stride: u64,
material_specular_buffer: Option<VulkanAllocatedBuffer>,
material_specular_stride: u64,
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
framebuffers: Vec<vk::Framebuffer>,
command_buffers: Vec<vk::CommandBuffer>,
@@ -53,6 +100,7 @@ pub(super) fn create_swapchain_resources(
vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer,
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport,
reuse_command_pool: bool,
@@ -74,16 +122,57 @@ pub(super) fn create_swapchain_resources(
swapchain.report.plan.format.format,
)?,
readback_buffers: Vec::new(),
depth_attachment: None,
depth_attachments: Vec::new(),
render_finished: Vec::new(),
render_pass: None,
pipeline_layout: None,
descriptor_set_layout: None,
descriptor_pool: None,
sampler: None,
frame_uniform_buffer: None,
frame_uniform_stride: 0,
material_specular_buffer: None,
material_specular_stride: 0,
pipelines: BTreeMap::new(),
framebuffers: Vec::new(),
command_buffers: Vec::new(),
};
let semaphore_info = vk::SemaphoreCreateInfo::default();
for _ in &images {
// SAFETY: The semaphore belongs to this live logical device and is
// destroyed with the swapchain resources.
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
Ok(semaphore) => partial.render_finished.push(semaphore),
Err(result) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSemaphore(render_finished)",
result,
});
}
}
}
let (frame_uniform_buffer, frame_uniform_stride) =
match super::create_frame_uniform_buffer(instance, device) {
Ok(buffer) => buffer,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
partial.frame_uniform_stride = frame_uniform_stride;
partial.frame_uniform_buffer = Some(frame_uniform_buffer);
let material_count = texture_slot_indices.len() / 4;
let (material_specular_buffer, material_specular_stride) =
match create_material_specular_buffer(instance, device, material_count) {
Ok(buffer) => buffer,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
partial.material_specular_stride = material_specular_stride;
partial.material_specular_buffer = Some(material_specular_buffer);
let (
depth_attachment,
render_pass,
@@ -99,8 +188,17 @@ pub(super) fn create_swapchain_resources(
swapchain.report.plan.format.format,
swapchain.report.plan.extent,
textures,
texture_slot_indices,
draw_ranges,
depth_request,
partial
.frame_uniform_buffer
.as_ref()
.expect("frame uniform buffer is present before descriptor creation"),
partial
.material_specular_buffer
.as_ref()
.expect("material specular buffer is present before descriptor creation"),
) {
Ok(bundle) => bundle,
Err(error) => {
@@ -108,19 +206,35 @@ pub(super) fn create_swapchain_resources(
return Err(error);
}
};
let depth_view = depth_attachment.image.view;
partial.depth_attachment = Some(depth_attachment);
// Keep every handle from the bundle in the partial owner before creating
// the remaining per-image depth attachments. That loop can fail after the
// first attachment, and its error path must destroy the whole bundle.
partial.depth_attachments.push(depth_attachment);
partial.render_pass = Some(render_pass);
partial.pipeline_layout = Some(pipeline_layout);
partial.descriptor_set_layout = Some(descriptor_set_layout);
partial.descriptor_pool = Some(descriptor_pool);
partial.sampler = Some(sampler);
partial.pipelines = pipelines;
for _ in 1..images.len() {
match create_depth_attachment(
instance,
device,
swapchain.report.plan.extent,
depth_request,
) {
Ok(attachment) => partial.depth_attachments.push(attachment),
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
}
}
let framebuffers = match create_swapchain_framebuffers(
device,
render_pass,
&partial.image_views,
depth_view,
&partial.depth_attachments,
swapchain.report.plan.extent,
) {
Ok(framebuffers) => framebuffers,
@@ -168,24 +282,44 @@ pub(super) fn create_swapchain_resources(
};
partial.command_buffers = command_buffers;
let _ = (vertex_buffer, index_buffer);
let depth_attachment =
partial
.depth_attachment
.take()
.ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "depth attachment ownership after swapchain setup",
})?;
if partial.depth_attachments.len() != images.len()
|| partial.render_finished.len() != images.len()
{
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(VulkanSmokeRendererError::InvariantViolation {
context: "swapchain per-image resource count after setup",
});
}
Ok(VulkanSwapchainResources {
image_views: partial.image_views,
images,
readback_buffers: partial.readback_buffers,
depth_attachment,
render_pass,
pipeline_layout,
descriptor_set_layout,
descriptor_pool,
depth_attachments: partial.depth_attachments,
render_finished: partial.render_finished,
render_pass: partial
.render_pass
.expect("render pass is present after successful swapchain setup"),
pipeline_layout: partial
.pipeline_layout
.expect("pipeline layout is present after successful swapchain setup"),
descriptor_set_layout: partial
.descriptor_set_layout
.expect("descriptor set layout is present after successful swapchain setup"),
descriptor_pool: partial
.descriptor_pool
.expect("descriptor pool is present after successful swapchain setup"),
descriptor_sets,
sampler,
sampler: partial
.sampler
.expect("sampler is present after successful swapchain setup"),
frame_uniform_buffer: partial
.frame_uniform_buffer
.expect("frame uniform buffer is present after successful swapchain setup"),
frame_uniform_stride: partial.frame_uniform_stride,
material_specular_buffer: partial
.material_specular_buffer
.expect("material specular buffer is present after successful swapchain setup"),
material_specular_stride: partial.material_specular_stride,
pipelines: partial.pipelines,
framebuffers: partial.framebuffers,
command_buffers: partial.command_buffers,
@@ -210,8 +344,11 @@ fn create_swapchain_pipeline_bundle(
format: i32,
extent: (u32, u32),
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport,
frame_uniform_buffer: &VulkanAllocatedBuffer,
material_specular_buffer: &VulkanAllocatedBuffer,
) -> Result<
(
VulkanDepthAttachment,
@@ -234,7 +371,14 @@ fn create_swapchain_pipeline_bundle(
}
};
let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) =
create_texture_descriptor_bundle(device, textures).inspect_err(|_| {
create_texture_descriptor_bundle(
device,
textures,
texture_slot_indices,
frame_uniform_buffer,
material_specular_buffer,
)
.inspect_err(|_| {
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_render_pass(render_pass, None) };
destroy_depth_attachment(device, &depth_attachment);
@@ -289,12 +433,23 @@ fn create_swapchain_framebuffers(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
image_views: &[vk::ImageView],
depth_view: vk::ImageView,
depth_attachments: &[VulkanDepthAttachment],
extent: (u32, u32),
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
if image_views.len() != depth_attachments.len() {
return Err(VulkanSmokeRendererError::InvariantViolation {
context: "swapchain color/depth attachment count",
});
}
let mut framebuffers = Vec::with_capacity(image_views.len());
for image_view in image_views.iter().copied() {
match create_framebuffer(device, render_pass, image_view, depth_view, extent) {
for (image_view, depth_attachment) in image_views.iter().copied().zip(depth_attachments) {
match create_framebuffer(
device,
render_pass,
image_view,
depth_attachment.image.view,
extent,
) {
Ok(framebuffer) => framebuffers.push(framebuffer),
Err(error) => {
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
@@ -339,7 +494,7 @@ fn create_image_view(
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::from_raw(format))
.subresource_range(color_subresource_range());
.subresource_range(color_subresource_range(1));
// SAFETY: The image comes from the live swapchain and the subresource range covers its color aspect.
unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
@@ -384,10 +539,21 @@ fn create_render_pass(
let dependency = vk::SubpassDependency::default()
.src_subpass(vk::SUBPASS_EXTERNAL)
.dst_subpass(0)
.src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
.src_stage_mask(
vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
)
.dst_stage_mask(
vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
)
.src_access_mask(vk::AccessFlags::empty())
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
.dst_access_mask(
vk::AccessFlags::COLOR_ATTACHMENT_WRITE
| vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
);
let attachments = [color_attachment, depth_attachment];
let subpasses = [subpass];
let dependencies = [dependency];
@@ -409,16 +575,16 @@ fn create_pipeline_layout(
descriptor_set_layout: vk::DescriptorSetLayout,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
let set_layouts = [descriptor_set_layout];
let push_constant_ranges = [
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX)
// Material data occupies one 128-byte push-constant allocation shared by
// both stages; the vertex stage also reads its specular pair from the
// dynamic storage buffer at descriptor binding 5. Camera data is supplied
// by the dynamic frame UBO at descriptor binding 4.
// Expose the complete block from offset zero so drivers validate the
// fragment member's declared offset against the pipeline layout.
let push_constant_ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT)
.offset(0)
.size(64),
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
.offset(64)
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
.size(32 * u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX))];
let create_info = vk::PipelineLayoutCreateInfo::default()
.set_layouts(&set_layouts)
.push_constant_ranges(&push_constant_ranges);
@@ -434,6 +600,9 @@ fn create_pipeline_layout(
fn create_texture_descriptor_bundle(
device: &VulkanLogicalDeviceProbe,
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
frame_uniform_buffer: &VulkanAllocatedBuffer,
material_specular_buffer: &VulkanAllocatedBuffer,
) -> Result<
(
vk::DescriptorSetLayout,
@@ -443,12 +612,32 @@ fn create_texture_descriptor_bundle(
),
VulkanSmokeRendererError,
> {
let binding = vk::DescriptorSetLayoutBinding::default()
.binding(0)
const TEXTURES_PER_MATERIAL: usize = 4;
const FRAME_BINDING: u32 = 4;
const MATERIAL_SPECULAR_BINDING: u32 = 5;
let mut bindings = (0..TEXTURES_PER_MATERIAL)
.map(|binding| {
vk::DescriptorSetLayoutBinding::default()
.binding(u32::try_from(binding).unwrap_or(u32::MAX))
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::FRAGMENT);
let bindings = [binding];
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
})
.collect::<Vec<_>>();
bindings.push(
vk::DescriptorSetLayoutBinding::default()
.binding(FRAME_BINDING)
.descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT),
);
bindings.push(
vk::DescriptorSetLayoutBinding::default()
.binding(MATERIAL_SPECULAR_BINDING)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::VERTEX),
);
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
// SAFETY: The layout description is stack-owned and references no external memory.
let layout = unsafe {
@@ -460,23 +649,40 @@ fn create_texture_descriptor_bundle(
context: "vkCreateDescriptorSetLayout",
result,
})?;
let texture_count = u32::try_from(textures.len()).map_err(|_| {
let texture_count = u32::try_from(texture_slot_indices.len()).map_err(|_| {
VulkanSmokeRendererError::InvariantViolation {
context: "static material texture count exceeds Vulkan descriptor limit",
}
})?;
if texture_count == 0 {
if texture_count == 0
|| !texture_slot_indices
.len()
.is_multiple_of(TEXTURES_PER_MATERIAL)
|| texture_slot_indices
.iter()
.any(|texture_index| *texture_index >= textures.len())
{
// SAFETY: The layout was created above on this device and is rolled
// back before reporting malformed material-slot input.
unsafe { device.device().destroy_descriptor_set_layout(layout, None) };
return Err(VulkanSmokeRendererError::InvariantViolation {
context: "static material texture list is empty",
context: "static material texture slots are invalid",
});
}
let material_count = texture_count / u32::try_from(TEXTURES_PER_MATERIAL).unwrap_or(1);
let pool_size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(texture_count);
let pool_sizes = [pool_size];
let frame_pool_size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
.descriptor_count(material_count);
let material_specular_pool_size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
.descriptor_count(material_count);
let pool_sizes = [pool_size, frame_pool_size, material_specular_pool_size];
let pool_info = vk::DescriptorPoolCreateInfo::default()
.pool_sizes(&pool_sizes)
.max_sets(texture_count);
.max_sets(material_count);
let pool =
// SAFETY: The pool description is stack-owned and reserves exactly one descriptor.
unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| {
@@ -487,7 +693,7 @@ fn create_texture_descriptor_bundle(
result,
}
})?;
let layouts = vec![layout; textures.len()];
let layouts = vec![layout; usize::try_from(material_count).unwrap_or(0)];
let allocate_info = vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(pool)
.set_layouts(&layouts);
@@ -511,7 +717,9 @@ fn create_texture_descriptor_bundle(
.address_mode_u(vk::SamplerAddressMode::REPEAT)
.address_mode_v(vk::SamplerAddressMode::REPEAT)
.address_mode_w(vk::SamplerAddressMode::REPEAT)
.max_lod(0.0);
// The uploaded images carry a complete CPU-generated mip chain; the
// sampler clamps to the largest legal level for each image.
.max_lod(16.0);
let sampler =
// SAFETY: The sampler create info is stack-owned and has no unsupported optional features.
unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| {
@@ -525,27 +733,51 @@ fn create_texture_descriptor_bundle(
result,
}
})?;
let image_infos = textures
let image_infos = texture_slot_indices
.iter()
.map(|texture| {
.map(|texture_index| {
let texture = &textures[*texture_index];
vk::DescriptorImageInfo::default()
.sampler(sampler)
.image_view(texture.view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
})
.collect::<Vec<_>>();
let writes = descriptor_sets
.iter()
.copied()
.zip(image_infos.iter())
.map(|(descriptor_set, image_info)| {
let frame_info = vk::DescriptorBufferInfo::default()
.buffer(frame_uniform_buffer.buffer)
.offset(0)
.range(VULKAN_FRAME_UNIFORM_BYTES as u64);
let material_specular_info = vk::DescriptorBufferInfo::default()
.buffer(material_specular_buffer.buffer)
.offset(0)
.range(VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64);
let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 2));
for (set_index, descriptor_set) in descriptor_sets.iter().copied().enumerate() {
for binding in 0..TEXTURES_PER_MATERIAL {
let image_info = &image_infos[set_index * TEXTURES_PER_MATERIAL + binding];
writes.push(
vk::WriteDescriptorSet::default()
.dst_set(descriptor_set)
.dst_binding(0)
.dst_binding(u32::try_from(binding).unwrap_or(u32::MAX))
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(std::slice::from_ref(image_info))
})
.collect::<Vec<_>>();
.image_info(std::slice::from_ref(image_info)),
);
}
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.
unsafe { device.device().update_descriptor_sets(&writes, &[]) };
Ok((layout, pool, descriptor_sets, sampler))
@@ -598,8 +830,9 @@ fn create_graphics_pipeline(
extent: (u32, u32),
state: LegacyPipelineState,
) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS, "vertex")?;
let fragment_shader =
match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS, "fragment") {
Ok(module) => module,
Err(error) => {
// SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure.
@@ -620,7 +853,7 @@ fn create_graphics_pipeline(
];
let vertex_binding = vk::VertexInputBindingDescription::default()
.binding(0)
.stride(u32::try_from(8 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
.stride(u32::try_from(14 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
.input_rate(vk::VertexInputRate::VERTEX);
let vertex_attributes = [
vk::VertexInputAttributeDescription::default()
@@ -636,8 +869,23 @@ fn create_graphics_pipeline(
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(2)
.format(vk::Format::R32G32_SFLOAT)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(u32::try_from(6 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(3)
.format(vk::Format::R32G32_SFLOAT)
.offset(u32::try_from(9 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(4)
.format(vk::Format::R32G32_SFLOAT)
.offset(u32::try_from(11 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(5)
.format(vk::Format::R32_SFLOAT)
.offset(u32::try_from(13 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
let vertex_bindings = [vertex_binding];
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
@@ -685,6 +933,21 @@ fn create_graphics_pipeline(
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
.depth_bounds_test_enable(false)
.stencil_test_enable(false);
let (blend_enable, src_color_blend_factor, dst_color_blend_factor) = match state.blend {
LegacyBlendMode::Opaque => (false, vk::BlendFactor::ONE, vk::BlendFactor::ZERO),
LegacyBlendMode::SourceAlpha => (
true,
vk::BlendFactor::SRC_ALPHA,
vk::BlendFactor::ONE_MINUS_SRC_ALPHA,
),
LegacyBlendMode::Additive => (true, vk::BlendFactor::SRC_ALPHA, vk::BlendFactor::ONE),
LegacyBlendMode::ZeroSourceColor => {
(true, vk::BlendFactor::ZERO, vk::BlendFactor::SRC_COLOR)
}
LegacyBlendMode::DestColorSourceColor => {
(true, vk::BlendFactor::DST_COLOR, vk::BlendFactor::SRC_COLOR)
}
};
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
.color_write_mask(
vk::ColorComponentFlags::R
@@ -692,12 +955,12 @@ fn create_graphics_pipeline(
| vk::ColorComponentFlags::B
| vk::ColorComponentFlags::A,
)
.blend_enable(state.blend == LegacyBlendMode::SourceAlpha)
.src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
.blend_enable(blend_enable)
.src_color_blend_factor(src_color_blend_factor)
.dst_color_blend_factor(dst_color_blend_factor)
.color_blend_op(vk::BlendOp::ADD)
.src_alpha_blend_factor(vk::BlendFactor::ONE)
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
.src_alpha_blend_factor(src_color_blend_factor)
.dst_alpha_blend_factor(dst_color_blend_factor)
.alpha_blend_op(vk::BlendOp::ADD);
let color_blend_attachments = [color_blend_attachment];
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
@@ -738,12 +1001,17 @@ fn create_graphics_pipeline(
fn create_shader_module(
device: &VulkanLogicalDeviceProbe,
words: &[u32],
stage: &'static str,
) -> Result<vk::ShaderModule, VulkanSmokeRendererError> {
let create_info = vk::ShaderModuleCreateInfo::default().code(words);
// SAFETY: The SPIR-V slice points to static checked-in words and lives for the duration of the call.
unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateShaderModule",
context: if stage == "vertex" {
"vkCreateShaderModule(vertex)"
} else {
"vkCreateShaderModule(fragment)"
},
result: error,
}
})
@@ -821,7 +1089,9 @@ pub(super) fn destroy_swapchain_resources(
device
.device()
.destroy_render_pass(resources.render_pass, None);
destroy_depth_attachment(device, &resources.depth_attachment);
for depth_attachment in resources.depth_attachments {
destroy_depth_attachment(device, &depth_attachment);
}
for image_view in resources.image_views {
device.device().destroy_image_view(image_view, None);
}
@@ -829,6 +1099,21 @@ pub(super) fn destroy_swapchain_resources(
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
for semaphore in resources.render_finished {
device.device().destroy_semaphore(semaphore, None);
}
device
.device()
.destroy_buffer(resources.frame_uniform_buffer.buffer, None);
device
.device()
.free_memory(resources.frame_uniform_buffer.memory, None);
device
.device()
.destroy_buffer(resources.material_specular_buffer.buffer, None);
device
.device()
.free_memory(resources.material_specular_buffer.memory, None);
}
}
@@ -871,7 +1156,7 @@ fn destroy_partial_swapchain_resources(
if let Some(render_pass) = partial.render_pass {
device.device().destroy_render_pass(render_pass, None);
}
if let Some(depth_attachment) = partial.depth_attachment {
for depth_attachment in partial.depth_attachments {
destroy_depth_attachment(device, &depth_attachment);
}
for image_view in partial.image_views {
@@ -881,5 +1166,16 @@ fn destroy_partial_swapchain_resources(
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
for semaphore in partial.render_finished {
device.device().destroy_semaphore(semaphore, None);
}
if let Some(buffer) = partial.frame_uniform_buffer {
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
if let Some(buffer) = partial.material_specular_buffer {
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
}
}
@@ -386,13 +386,13 @@ fn static_surface_extension_name_is_decoded() {
}
#[test]
fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
fn swapchain_plan_prefers_unorm_mailbox_and_clamps_extent() {
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
assert_eq!(
plan.format,
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
@@ -444,7 +444,7 @@ fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() {
assert_eq!(
plan.format,
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
@@ -479,6 +479,66 @@ fn checked_in_shaders_have_valid_spirv_containers() {
assert_eq!(validate_triangle_shaders(), Ok(()));
}
#[test]
fn fragment_shader_dispatches_all_four_material_modes() {
let fragment = include_str!("../../shaders/triangle.frag");
assert!(fragment.contains("int mode = int(round(material.material_mode));"));
assert!(fragment.contains("mode == 0"));
assert!(fragment.contains("mode == 1"));
assert!(fragment.contains("mode == 2"));
assert!(fragment.contains("mode == 3"));
assert!(!fragment.contains("material_mode > 1.5"));
assert!(!fragment.contains("material_mode > 2.5"));
}
#[test]
fn native_specular_reference_uses_squared_power_and_total_overflow_curve() {
fn reference_power(cosine: f32, power: u8) -> f32 {
if power == 0 {
return 0.0;
}
let mut result = cosine;
for _ in 0..power.saturating_sub(1) {
result *= result;
}
result
}
fn reference_curve(value: f32) -> f32 {
if value <= 1.0 {
0.8 * value
} else if value <= 3.0 {
0.1 * value + 0.7
} else {
1.0
}
}
assert_eq!(reference_power(0.5, 0), 0.0);
assert!((reference_power(0.5, 1) - 0.5).abs() < 1e-6);
assert!((reference_power(0.5, 2) - 0.25).abs() < 1e-6);
assert!((reference_power(0.5, 3) - 0.0625).abs() < 1e-6);
let compressed_diffuse = 1.5;
let material_specular = 0.75;
assert!((reference_curve(material_specular + (compressed_diffuse - 1.0)) - 0.825).abs() < 1e-6);
let vertex = include_str!("../../shaders/triangle.vert");
assert!(vertex.contains("result *= result;"));
assert!(
vertex.contains("vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));")
);
assert!(vertex.contains("if (power <= 0.0 || cosine <= 0.0)"));
}
#[test]
fn shader_computes_radial_fog_per_vertex_and_interpolates_it() {
let vertex = include_str!("../../shaders/triangle.vert");
let fragment = include_str!("../../shaders/triangle.frag");
assert!(vertex.contains("out_fog_factor = native_fog_factor(in_position);"));
assert!(fragment.contains("layout(location = 4) in float in_fog_factor;"));
assert!(!fragment.contains("distance(in_world_position"));
assert!(!fragment.contains("float fog_factor()"));
}
#[test]
fn shader_container_rejects_invalid_spirv() {
assert_eq!(
+16 -1
View File
@@ -458,9 +458,24 @@ fn select_surface_format(
.iter()
.copied()
.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.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
})
})
.or_else(|| formats.first().copied())
.ok_or(VulkanSwapchainError::MissingSurfaceFormat)
}
@@ -470,7 +485,7 @@ fn undefined_surface_format_override(
) -> Option<VulkanSurfaceFormat> {
match formats {
[format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: format.color_space,
}),
_ => None,
+7
View File
@@ -6,14 +6,21 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-animation = { path = "../../crates/fparkan-animation" }
fparkan-assets = { path = "../../crates/fparkan-assets" }
fparkan-fx = { path = "../../crates/fparkan-fx" }
fparkan-path = { path = "../../crates/fparkan-path" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-msh = { path = "../../crates/fparkan-msh" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-terrain = { path = "../../crates/fparkan-terrain" }
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "vorbis", "wav", "symphonia-adpcm"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
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)?);
validate_time(time)?;
let raw_rotation = [
let disk_wxyz = [
f32::from(read_i16(bytes, 16)?) / 32767.0,
f32::from(read_i16(bytes, 18)?) / 32767.0,
f32::from(read_i16(bytes, 20)?) / 32767.0,
f32::from(read_i16(bytes, 22)?) / 32767.0,
];
// Native AniMesh stores the scalar first (WXYZ). The portable pose
// contract is XYZW, matching the rotate/multiply helpers below. Keep
// the decoded values unnormalized; sampling_pose owns normalization.
let raw_rotation = [disk_wxyz[1], disk_wxyz[2], disk_wxyz[3], disk_wxyz[0]];
Ok(Self {
time,
pose: Pose {
@@ -870,9 +874,47 @@ mod tests {
assert_eq!(key.time, AnimationTime(12.5));
assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
assert!(key.pose.rotation[1] < 0.0);
assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
assert!(key.pose.rotation[0] < 0.0);
assert!((key.pose.rotation[0] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
assert!((key.pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
}
#[test]
fn anim_key24_preserves_disk_wxyz_as_internal_xyzw() {
let mut bytes = [0_u8; 24];
bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
let key = AnimKey24::decode(&bytes).expect("bunker key");
let expected = [0.0, 0.0, 3211.0 / 32767.0, 32609.0 / 32767.0];
assert_eq!(key.pose.rotation, expected);
}
#[test]
fn bunker_fallback_pose_rotates_a_point_with_native_matrix_contract() {
let mut bytes = [0_u8; 24];
bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
let pose = AnimKey24::decode(&bytes)
.expect("bunker key")
.sampling_pose();
let rotated = rotate_point(pose.rotation, [0.0, 1.0, 0.0]);
assert!((pose.rotation[0] - 0.0).abs() < 0.000_001);
assert!((pose.rotation[1] - 0.0).abs() < 0.000_001);
assert!((pose.rotation[2] - 0.097_995_8).abs() < 0.000_01);
assert!((pose.rotation[3] - 0.995_186_8).abs() < 0.000_01);
assert!((rotated[0] + 0.195_048_3).abs() < 0.000_01);
assert!((rotated[1] - 0.980_793_6).abs() < 0.000_01);
assert!(rotated[2].abs() < 0.000_01);
}
#[test]
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
//! are backed by runtime-captured evidence.
pub mod atmosphere;
pub mod environment;
pub mod shadow;
pub mod sky;
use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc;
@@ -85,8 +90,13 @@ pub struct FxCommand {
pub word: u32,
/// Decoded opcode.
pub opcode: FxOpcode,
/// Enabled bit.
pub enabled: bool,
/// Native command flag stored in bit 8 of the command word.
///
/// This flag is passed to the native command object as a mode/feature
/// flag. It does not disable the command: real effects such as
/// `env_lightning` use zero for all three command flags and still execute
/// opcode 3, opcode 1, and opcode 2.
pub native_flag: bool,
/// Command body after the word.
pub raw_body: Arc<[u8]>,
/// Resource references discovered in known command layouts.
@@ -314,7 +324,7 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
commands.push(FxCommand {
word,
opcode,
enabled: ((word >> 8) & 1) != 0,
native_flag: ((word >> 8) & 1) != 0,
raw_body,
resource_refs,
});
@@ -369,9 +379,6 @@ pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
return Ok(());
}
for (index, command) in state.document.commands.iter().enumerate() {
if !command.enabled {
continue;
}
let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
if command.opcode == FxOpcode::Op2 {
out.push(FxEmission::Sound(FxSoundEvent { command_index }));
@@ -605,7 +612,7 @@ mod tests {
assert_eq!(document.header().command_count, 2);
assert_eq!(document.commands()[0].opcode, FxOpcode::Op2);
assert!(document.commands()[0].enabled);
assert!(document.commands()[0].native_flag);
assert_eq!(
document.commands()[0].resource_refs[0].archive_name(),
b"sounds.lib"
@@ -640,6 +647,25 @@ mod tests {
assert!(document.commands()[0].raw_body.is_empty());
}
#[test]
fn zero_native_flags_still_emit_every_command() {
let mut bytes = header(3);
bytes.extend_from_slice(&command(0x0003, 200));
bytes.extend_from_slice(&command(0x0001, 224));
bytes.extend_from_slice(&command(0x0002, 148));
let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
assert!(document
.commands()
.iter()
.all(|command| !command.native_flag));
let state = create_instance(document, FxSeed(9), Transform::default()).expect("state");
assert_eq!(
canonical_emission_capture(&state).expect("capture"),
b"P,0, Op3\nP,1, Op1\nS,2\n"
);
}
#[test]
fn rejects_unknown_opcode_at_command_index() {
let mut bytes = header(1);
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;
/// Texture coordinate stream.
pub const STREAM_UV0: u32 = 5;
/// Optional secondary texture coordinate stream used by native lightmap/detail
/// stages. MSH stores it as the type-18 four-byte packed UV stream.
pub const STREAM_UV1: u32 = 18;
/// Triangle index stream.
pub const STREAM_INDICES: u32 = 6;
/// Animation key stream.
@@ -81,14 +84,20 @@ pub struct ModelAsset {
pub node_count: usize,
/// Raw node table.
pub nodes_raw: Vec<u8>,
/// Model-level bounding sphere center from the Res2 header.
pub bounding_sphere_center: [f32; 3],
/// Model-level bounding sphere radius from the Res2 header.
pub bounding_sphere_radius: f32,
/// Slot table.
pub slots: Vec<Slot>,
/// Vertex positions.
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
/// Optional texture coordinates.
pub uv0: Option<Vec<[i16; 2]>>,
/// Optional primary packed texture coordinates from type 5.
pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
/// Triangle indices.
pub indices: Vec<u16>,
/// Draw batches.
@@ -167,18 +176,24 @@ pub struct Slot {
pub struct Batch {
/// Batch flags.
pub batch_flags: u16,
/// Material index.
pub material_index: u16,
/// Opaque field.
pub opaque4: u16,
/// Opaque field.
pub opaque6: u16,
/// High material selector/slot field at byte offset `+0x02`.
///
/// The ordinary WEAR material selector is the byte at `+0x04`. This
/// field is retained separately because the native path can override it
/// with a forced material index.
pub material_index_hi: u16,
/// WEAR material selector at byte offset `+0x04`.
pub material_index: u8,
/// Lightmap selector at byte offset `+0x05`.
pub lightmap_index: u8,
/// Local batch index at byte offset `+0x06`.
pub local_batch_index: u16,
/// Index count.
pub index_count: u16,
/// First index offset.
pub index_start: u32,
/// Opaque field.
pub opaque14: u16,
/// Vertex count at byte offset `+0x0E`.
pub vertex_count: u16,
/// Base vertex.
pub base_vertex: u32,
}
@@ -194,8 +209,10 @@ pub struct VertexStreams {
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
/// Optional texture coordinates.
pub uv0: Option<Vec<[i16; 2]>>,
/// Optional primary packed texture coordinates from type 5.
pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
}
/// Preserved non-core stream.
@@ -209,11 +226,11 @@ pub struct PreservedStream {
pub bytes: std::sync::Arc<[u8]>,
}
/// LOD id.
/// Native state id kept under the historical `Lod` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Lod(pub u8);
/// Group id.
/// Native LOD id kept under the historical `Group` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Group(pub u8);
@@ -377,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> {
Ok(model)
}
/// Returns the selected slot for a node/lod/group tuple.
/// Returns the selected slot for a node/native-state/native-LOD tuple.
///
/// The argument names are retained for API compatibility: `Lod` is the
/// three-value native state and `Group` is the five-value native LOD.
#[must_use]
pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> {
if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 {
@@ -417,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
})
}
/// Returns the source fallback pose of a standard node.
/// Returns the fallback key as a native Node38 source-local pose.
///
/// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before
/// assembling a runtime hierarchy.
/// assembling a runtime hierarchy. Its quaternion is converted to match native
/// AniMesh's quaternion-to-matrix convention.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
@@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
.keys
.get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose)
.map(node38_native_local_pose)
}
/// Converts a sampled MSH source-local quaternion into the native AniMesh
/// quaternion-matrix convention before Node38 hierarchy composition. Native
/// AniMesh's matrix coefficients are the transpose of the portable `Pose`
/// basis.
fn node38_native_local_pose(mut pose: Pose) -> Pose {
pose.rotation[0] = -pose.rotation[0];
pose.rotation[1] = -pose.rotation[1];
pose.rotation[2] = -pose.rotation[2];
pose
}
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
@@ -462,8 +495,9 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
evaluate_hierarchy(&parents, &poses).ok()
}
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
/// model at one explicitly supplied logical frame.
/// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical
/// frame, using portable key sampling and native AniMesh local-matrix
/// convention.
///
/// A node without a usable type-19 map, or a requested frame outside the
/// declared map length, retains its exact fallback key. Mapped keys are
@@ -475,6 +509,59 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
node38_sampled_hierarchy_with_selector(model, |_| {
Some((u32::from(frame), AnimationTime(f32::from(frame))))
})
}
/// Evaluates a standard `Node38` hierarchy with an independent floating-point
/// sample time for each node.
///
/// `Some(time)` uses the nearest-even integer to `time - 0.5` as the type-19
/// frame-map selector, then samples the selected key and its immediate
/// successor at the original fractional time. This matches the native
/// sampler's selector calculation while keeping interpolation time fractional.
/// The selector uses Rust's default-nearest, ties-to-even rounding. This is
/// the portable counterpart of the native x87 conversion under its default
/// rounding mode; it does not claim parity under every x87 control-word mode.
/// A missing time uses that node's exact fallback key. Supplied times must be
/// finite and non-negative; the caller owns choosing the per-node times.
///
/// Returns `None` when the model is not a complete standard-node animation,
/// the slice does not match the node count, any supplied time is invalid, or a
/// frame-map entry cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy_at_times(
model: &ModelAsset,
node_times: &[Option<AnimationTime>],
) -> Option<NodePoseBuffer> {
if node_times.len() != model.node_count
|| node_times
.iter()
.flatten()
.any(|time| !time.0.is_finite() || time.0 < 0.0)
{
return None;
}
node38_sampled_hierarchy_with_selector(model, |index| {
let time = node_times[index]?;
let selector = (time.0 - 0.5).round_ties_even();
// A finite, non-negative time can still be beyond the representable
// map range. Saturate to a frame that will resolve to the node's
// fallback key rather than allowing a float-to-index wraparound.
let map_frame = if selector < 0.0 || selector >= u32::MAX as f32 {
u32::MAX
} else {
selector as u32
};
Some((map_frame, time))
})
}
fn node38_sampled_hierarchy_with_selector(
model: &ModelAsset,
mut selector_for_node: impl FnMut(usize) -> Option<(u32, AnimationTime)>,
) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
@@ -492,21 +579,36 @@ pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePo
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
let key_index =
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
let sample = selector_for_node(index);
let key_index = if let Some((map_frame, _)) = sample {
if metadata.anim_map_start == u16::MAX
|| map_frame == u32::MAX
|| map_frame >= animation.frame_count
{
fallback_index
} else {
let mapped_index =
usize::from(*animation.frame_map.get(
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
)?);
let mapped_index = usize::from(
*animation.frame_map.get(
usize::from(metadata.anim_map_start)
.checked_add(usize::try_from(map_frame).ok()?)?,
)?,
);
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
}
} 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);
poses.push(pose);
}
@@ -517,7 +619,7 @@ fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
frame: u16,
sample_time: AnimationTime,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
@@ -541,7 +643,7 @@ fn sample_node38_key_pair(
],
)
.ok()?;
track.sample(AnimationTime(f32::from(frame))).ok()
track.sample(sample_time).ok()
}
/// Returns draw batches for a validated slot.
@@ -667,6 +769,8 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
}
let node_count = nodes_stream.bytes.len() / node_stride;
let (bounding_sphere_center, bounding_sphere_radius) =
parse_res2_bounding_sphere(&slots_stream.bytes)?;
let slots = parse_slots(&slots_stream.bytes)?;
let positions = parse_positions(&positions_stream.bytes)?;
let indices = parse_u16_array(&indices_stream.bytes, "Res6")?;
@@ -678,7 +782,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
.map(|raw| parse_i8x4_array(&raw.bytes, "Res4"))
.transpose()?;
let uv0 = read_optional_stream(document, STREAM_UV0)?
.map(|raw| parse_i16x2_array(&raw.bytes, "Res5"))
.map(|raw| parse_u16x2_array(&raw.bytes, "Res5"))
.transpose()?;
let uv1 = read_optional_stream(document, STREAM_UV1)?
.map(|raw| parse_secondary_uv_stream(raw, positions.len()))
.transpose()?;
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
@@ -689,10 +796,13 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
node_stride,
node_count,
nodes_raw: nodes_stream.bytes,
bounding_sphere_center,
bounding_sphere_radius,
slots,
positions,
normals,
uv0,
uv1,
indices,
batches,
node_names,
@@ -822,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
Ok(slots)
}
fn parse_res2_bounding_sphere(data: &[u8]) -> Result<([f32; 3], f32), MshError> {
if data.len() < 0x8C {
return Err(MshError::InvalidGeometry(format!(
"invalid Res2 size: {}",
data.len()
)));
}
let center = [
read_f32(data, 0x60)?,
read_f32(data, 0x64)?,
read_f32(data, 0x68)?,
];
let radius = read_f32(data, 0x6C)?;
if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius < 0.0 {
return Err(MshError::InvalidGeometry(
"invalid Res2 bounding sphere".to_string(),
));
}
Ok((center, radius))
}
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> {
if !data.len().is_multiple_of(12) {
return Err(invalid_resource_size("Res3", data.len(), 12));
@@ -845,12 +976,17 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
for offset in (0..data.len()).step_by(20) {
out.push(Batch {
batch_flags: read_u16_required(data, offset)?,
material_index: read_u16_required(data, offset + 2)?,
opaque4: read_u16_required(data, offset + 4)?,
opaque6: read_u16_required(data, offset + 6)?,
material_index_hi: read_u16_required(data, offset + 2)?,
material_index: *data
.get(offset + 4)
.ok_or_else(|| MshError::InvalidGeometry("batch material selector".to_string()))?,
lightmap_index: *data
.get(offset + 5)
.ok_or_else(|| MshError::InvalidGeometry("batch lightmap selector".to_string()))?,
local_batch_index: read_u16_required(data, offset + 6)?,
index_count: read_u16_required(data, offset + 8)?,
index_start: read_u32(data, offset + 10)?,
opaque14: read_u16_required(data, offset + 14)?,
vertex_count: read_u16_required(data, offset + 14)?,
base_vertex: read_u32(data, offset + 16)?,
});
}
@@ -884,17 +1020,51 @@ fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>, Ms
Ok(out)
}
fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> {
fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[u16; 2]>, MshError> {
if !data.len().is_multiple_of(4) {
return Err(invalid_resource_size(label, data.len(), 4));
}
let mut out = Vec::with_capacity(data.len() / 4);
for offset in (0..data.len()).step_by(4) {
out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]);
out.push([
read_u16_required(data, offset)?,
read_u16_required(data, offset + 2)?,
]);
}
Ok(out)
}
fn parse_secondary_uv_stream(
stream: RawStream,
position_count: usize,
) -> Result<Vec<[u16; 2]>, MshError> {
if stream.attributes.attr3 != 4 {
return Err(MshError::InvalidGeometry(format!(
"invalid Res18 stride: expected 4, got {}",
stream.attributes.attr3
)));
}
let values = parse_u16x2_array(&stream.bytes, "Res18")?;
let declared_count = usize::try_from(stream.attributes.attr1).map_err(|_| {
MshError::InvalidGeometry("Res18 vertex count does not fit usize".to_string())
})?;
if declared_count != values.len() {
return Err(MshError::InvalidGeometry(format!(
"Res18 vertex count does not match payload: declared={}, decoded={}",
declared_count,
values.len()
)));
}
if values.len() != position_count {
return Err(MshError::InvalidGeometry(format!(
"Res18 vertex count does not match Res3: uv1={}, positions={}",
values.len(),
position_count
)));
}
Ok(values)
}
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> {
let mut out = Vec::with_capacity(node_count);
let mut offset = 0usize;
@@ -1038,16 +1208,6 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
Ok(u16::from_le_bytes(arr))
}
fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
let raw = bytes
.get(offset..offset.saturating_add(2))
.ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
let arr: [u8; 2] = raw
.try_into()
.map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
Ok(i16::from_le_bytes(arr))
}
fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
let byte = bytes
.get(offset)
@@ -1309,18 +1469,21 @@ mod tests {
let mut nodes = root;
nodes.extend(child);
let mut keys = Vec::new();
for (x, y, z, qz, qw) in [
// Type 8 stores WXYZ, which `AnimKey24::decode` reorders to XYZW.
// The native Node38 local-basis conversion happens before hierarchy
// evaluation, not in the generic animation decoder.
for (x, y, z, qw, qz) in [
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
(2.0, 0.0, 0.0, 32_767_i16, 0_i16),
] {
push_f32(&mut keys, x);
push_f32(&mut keys, y);
push_f32(&mut keys, z);
push_f32(&mut keys, 0.0);
push_u16(&mut keys, qw.cast_unsigned());
push_u16(&mut keys, 0);
push_u16(&mut keys, 0);
push_u16(&mut keys, qz.cast_unsigned());
push_u16(&mut keys, qw.cast_unsigned());
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
@@ -1336,7 +1499,78 @@ mod tests {
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
assert!((hierarchy.poses[1].translation[1] + 2.0).abs() < 0.001);
}
#[test]
fn node38_native_x_rotation_moves_child_translation_for_fallback_and_sampled_paths() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[4..6].copy_from_slice(&0_u16.to_le_bytes());
root[6..8].copy_from_slice(&1_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
child[6..8].copy_from_slice(&2_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let half_sqrt_two = std::f32::consts::FRAC_1_SQRT_2;
let root_rotation = [half_sqrt_two, 0.0, 0.0, half_sqrt_two];
let identity = [0.0, 0.0, 0.0, 1.0];
let child_translation = [0.0, -1.01209, -0.001792];
let model = ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [0.0; 3],
rotation: root_rotation,
},
},
AnimKey24 {
time: AnimationTime(1.0),
pose: Pose {
translation: [0.0; 3],
rotation: root_rotation,
},
},
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: child_translation,
rotation: identity,
},
},
],
frame_map: vec![0],
frame_count: 1,
}),
};
let fallback_root = node38_fallback_pose(&model, NodeId(0)).expect("fallback root pose");
assert!(fallback_root.rotation[0] < -0.7);
let fallback = node38_fallback_hierarchy(&model).expect("fallback hierarchy");
let sampled = node38_sampled_hierarchy(&model, 0).expect("sampled hierarchy");
for hierarchy in [&fallback, &sampled] {
let child_translation = hierarchy.poses[1].translation;
assert!(child_translation[0].abs() < 0.0001);
assert!((child_translation[1] + 0.001792).abs() < 0.0001);
assert!((child_translation[2] - 1.01209).abs() < 0.0001);
}
}
#[test]
@@ -1349,10 +1583,13 @@ mod tests {
node_stride: 38,
node_count: 1,
nodes_raw: node,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
@@ -1394,6 +1631,105 @@ mod tests {
);
}
#[test]
fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[4..6].copy_from_slice(&0_u16.to_le_bytes());
root[6..8].copy_from_slice(&4_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[4..6].copy_from_slice(&2_u16.to_le_bytes());
child[6..8].copy_from_slice(&5_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let model = ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [4.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [8.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(1.0),
pose: Pose {
translation: [20.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [30.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose {
translation: [100.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose {
translation: [300.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
],
frame_map: vec![0, 1, 0, 2],
frame_count: 4,
}),
};
let hierarchy = node38_sampled_hierarchy_at_times(
&model,
&[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))],
)
.expect("per-node fractional hierarchy");
// The root's tie selects map frame zero and interpolates to x=6. The
// child independently selects frame one and samples its own 1.55 time;
// its global pose includes the root translation.
assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON);
assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001);
let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None])
.expect("per-node fallback hierarchy");
assert_eq!(fallback.poses[1].translation[0], 306.0);
assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none());
for invalid in [-1.0, f32::NAN, f32::INFINITY] {
assert!(node38_sampled_hierarchy_at_times(
&model,
&[Some(AnimationTime(invalid)), None]
)
.is_none());
}
}
#[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
@@ -1420,6 +1756,44 @@ mod tests {
assert!(matches!(err, MshError::InvalidGeometry(_)));
}
#[test]
fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() {
let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &valid_slots),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
]))
.expect("nested");
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]);
assert_eq!(model.bounding_sphere_radius, 4.5);
for (offset, value) in [
(0x60, f32::NAN),
(0x68, f32::INFINITY),
(0x6C, f32::NAN),
(0x6C, -1.0),
] {
let mut slots = slots_payload(&[]);
slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
]))
.expect("nested");
assert!(matches!(
validate_msh(&decode_msh(&document).expect("msh")),
Err(MshError::InvalidGeometry(_))
));
}
}
#[test]
fn slot_batch_range_out_of_bounds_is_error() {
let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
@@ -1448,6 +1822,7 @@ mod tests {
(STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]),
(STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]),
(STREAM_UV0, b"Res5".as_slice(), vec![0; 3]),
(STREAM_UV1, b"Res18".as_slice(), vec![0; 3]),
(STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]),
] {
let slots = slots_payload(&[]);
@@ -1471,6 +1846,127 @@ mod tests {
}
}
#[test]
fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]);
let mut uv1 = Vec::new();
for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] {
push_u16(&mut uv1, pair[0]);
push_u16(&mut uv1, pair[1]);
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1),
]))
.expect("nested");
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.positions.len(), 4);
assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]]));
}
#[test]
fn type18_secondary_uv_rejects_untrusted_count_or_stride() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]);
for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] {
let mut uv1 = Vec::new();
push_u16(&mut uv1, 2);
push_u16(&mut uv1, 5);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1),
]))
.expect("nested");
let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid");
let message = err.to_string();
assert!(message.contains(expected), "{message}");
}
}
#[test]
fn installed_gog_secondary_uv_fixture_is_aligned_and_real() {
let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else {
return;
};
let root = PathBuf::from(root);
assert!(
root.is_dir(),
"FPARKAN_GAME_ROOT is missing: {}",
root.display()
);
let mut models_with_uv1 = 0usize;
let mut fixture_found = false;
for path in files_under(&root) {
if !path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("rlb"))
{
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(archive) =
fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
else {
continue;
};
for entry in archive
.entries()
.iter()
.filter(|entry| has_msh_extension(entry.name_bytes()))
{
let payload = archive.payload(entry.id()).expect("payload");
let nested = fparkan_nres::decode(
Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let msh = decode_msh(&nested)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let model = validate_msh(&msh)
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
let Some(uv1) = model.uv1.as_ref() else {
continue;
};
models_with_uv1 += 1;
assert_eq!(
uv1.len(),
model.positions.len(),
"{path:?} {:?}",
entry.name_bytes()
);
let descriptor = msh
.streams()
.iter()
.find(|stream| stream.type_id == STREAM_UV1)
.expect("Res18 descriptor");
assert_eq!(descriptor.attributes.attr1 as usize, uv1.len());
assert_eq!(descriptor.attributes.attr3, 4);
if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") {
assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]);
fixture_found = true;
}
}
}
assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH");
assert!(
fixture_found,
"FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture"
);
}
#[test]
fn batch20_uses_unaligned_field_offsets() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]]);
@@ -1488,12 +1984,13 @@ mod tests {
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.batches[0].batch_flags, 0x1100);
assert_eq!(model.batches[0].material_index, 0x2200);
assert_eq!(model.batches[0].opaque4, 0x3300);
assert_eq!(model.batches[0].opaque6, 0x4400);
assert_eq!(model.batches[0].material_index_hi, 0x2200);
assert_eq!(model.batches[0].material_index, 0x00);
assert_eq!(model.batches[0].lightmap_index, 0x33);
assert_eq!(model.batches[0].local_batch_index, 0x4400);
assert_eq!(model.batches[0].index_count, 1);
assert_eq!(model.batches[0].index_start, 0);
assert_eq!(model.batches[0].opaque14, 0x5500);
assert_eq!(model.batches[0].vertex_count, 0x5500);
assert_eq!(model.batches[0].base_vertex, 0);
}
@@ -1587,18 +2084,26 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_msh_assets_validate() {
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
fn licensed_corpus_part1_msh_assets_validate() {
validate_licensed_corpus_msh_assets("IS", 435);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part2_msh_assets_validate() {
validate_licensed_corpus_msh_assets("IS2", 511);
}
fn validate_licensed_corpus_msh_assets(corpus: &str, expected: usize) {
let root = corpus_root(corpus);
let mut count = 0usize;
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(archive) = fparkan_nres::decode(
Arc::from(bytes.into_boxed_slice()),
ReadProfile::Compatible,
) else {
let Ok(archive) =
fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
else {
continue;
};
for entry in archive
@@ -1611,9 +2116,7 @@ mod tests {
Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
.unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()));
let msh = decode_msh(&nested).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
@@ -1625,7 +2128,6 @@ mod tests {
}
assert_eq!(count, expected, "{corpus} MSH count");
}
}
#[test]
#[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> {
TestEntry {
type_id,
attr1: 0,
attr3,
name,
payload,
}
}
fn stream_with_attr1<'a>(
type_id: u32,
attr1: u32,
attr3: u32,
name: &'a [u8],
payload: &'a [u8],
) -> TestEntry<'a> {
TestEntry {
type_id,
attr1,
attr3,
name,
payload,
@@ -1940,6 +2459,7 @@ mod tests {
struct TestEntry<'a> {
type_id: u32,
attr1: u32,
attr3: u32,
name: &'a [u8],
payload: &'a [u8],
@@ -1958,7 +2478,7 @@ mod tests {
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
for (idx, entry) in entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
push_u32(&mut out, 0);
push_u32(&mut out, entry.attr1);
push_u32(&mut out, 0);
push_u32(
&mut out,
@@ -2000,7 +2520,19 @@ mod tests {
}
fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> {
slots_payload_with_bounds([0.0; 3], 1.0, records)
}
fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec<u8>]) -> Vec<u8> {
let mut out = vec![0; 0x8c];
for (offset, value) in [
(0x60, center[0]),
(0x64, center[1]),
(0x68, center[2]),
(0x6C, radius),
] {
out[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
for record in records {
assert_eq!(record.len(), 68);
out.extend_from_slice(record);
+401 -16
View File
@@ -15,6 +15,9 @@ const UNIT_DAT_MIN_SIZE: usize = 0x48;
const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32;
/// `objects.rlb` external geometry entry type (`EXTO`).
pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO");
/// Prototype key.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct PrototypeKey(pub ResourceName);
@@ -61,10 +64,21 @@ pub struct UnitComponentRecord {
pub parent_or_link: i32,
/// Description raw bytes.
pub description_raw: [u8; 32],
/// Opaque tail.
/// Opaque record word at offset `0x68`.
pub tail0: u32,
/// Opaque tail.
pub tail1: u32,
/// Number of immediate children in the preorder unit tree.
pub immediate_child_count: u32,
}
/// One node in a decoded Unit DAT preorder tree.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnitComponentTreeNode {
/// Original preorder record index.
pub record_index: usize,
/// Parent record index, or `None` for the root.
pub parent_index: Option<usize>,
/// Direct child record indices in source order.
pub children: Vec<usize>,
}
/// Prototype geometry.
@@ -85,8 +99,18 @@ pub struct EffectivePrototype {
pub geometry: PrototypeGeometry,
/// Resolution source.
pub source: PrototypeSource,
/// Source registry entry type, when the prototype came from an NRes
/// object registry (for example `EXTO`, `INTO`, or `BTLU`).
pub source_type: Option<u32>,
/// Resource dependencies discovered while resolving this prototype.
pub dependencies: Vec<ResourceKey>,
/// Explicit control-data resource referenced by this prototype's resolved
/// `objects.rlb` registry inheritance chain, when present.
///
/// This is deliberately separate from visual dependencies: controller
/// state belongs to each component instance and must not alter shared
/// mesh/material cache identity.
pub control_resource: Option<ResourceKey>,
}
/// Prototype resolution source.
@@ -401,6 +425,8 @@ pub enum PrototypeError {
InvalidSize,
/// Invalid unit DAT magic.
InvalidUnitDatMagic(u32),
/// Unit DAT child counts do not describe one complete preorder tree.
InvalidUnitHierarchy(String),
/// Invalid path.
InvalidPath(String),
/// VFS error.
@@ -435,6 +461,9 @@ impl std::fmt::Display for PrototypeError {
Self::InvalidUnitDatMagic(magic) => {
write!(f, "invalid unit DAT magic: {magic:#010X}")
}
Self::InvalidUnitHierarchy(message) => {
write!(f, "invalid unit DAT preorder hierarchy: {message}")
}
Self::InvalidPath(value) => write!(f, "invalid path: {value}"),
Self::Vfs(source) => write!(f, "vfs error: {source}"),
Self::Resource(source) => write!(f, "resource error: {source}"),
@@ -446,7 +475,10 @@ impl std::error::Error for PrototypeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None,
Self::InvalidSize
| Self::InvalidUnitDatMagic(_)
| Self::InvalidUnitHierarchy(_)
| Self::InvalidPath(_) => None,
Self::Vfs(source) => Some(source),
Self::Resource(source) => Some(source),
}
@@ -512,7 +544,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
let parent_or_link = cursor.read_i32_le()?;
description_raw.copy_from_slice(cursor.read_exact(32)?);
let tail0 = cursor.read_u32_le()?;
let tail1 = cursor.read_u32_le()?;
let immediate_child_count = cursor.read_u32_le()?;
records.push(UnitComponentRecord {
archive_raw,
resource_raw,
@@ -520,7 +552,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
parent_or_link,
description_raw,
tail0,
tail1,
immediate_child_count,
});
}
cursor.require_eof()?;
@@ -530,6 +562,86 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
})
}
/// Builds the immediate-child tree encoded by Unit DAT record counts.
///
/// Records are stored in preorder. The final word of each 112-byte record is
/// the number of immediate children, so the tree can be reconstructed without
/// treating `parent_or_link` as a parent index. That field is retained as the
/// native mount-socket value for attachment consumers.
///
/// # Errors
///
/// Returns [`PrototypeError::InvalidUnitHierarchy`] when child counts consume
/// too few or too many records.
pub fn unit_component_tree(
records: &[UnitComponentRecord],
) -> Result<Vec<UnitComponentTreeNode>, PrototypeError> {
if records.is_empty() {
return Ok(Vec::new());
}
let root_child_count = usize::try_from(records[0].immediate_child_count).map_err(|_| {
PrototypeError::InvalidUnitHierarchy("record 0 child count does not fit usize".to_string())
})?;
let mut nodes = Vec::with_capacity(records.len());
nodes.push(UnitComponentTreeNode {
record_index: 0,
parent_index: None,
children: Vec::new(),
});
// Each open frame holds a preorder node and the direct children still due.
// This keeps hostile deep chains off the call stack.
let mut open_parents = vec![(0_usize, root_child_count)];
let mut cursor = 1_usize;
while cursor < records.len() {
while open_parents
.last()
.is_some_and(|(_, remaining_children)| *remaining_children == 0)
{
open_parents.pop();
}
let Some((parent_node_index, remaining_children)) = open_parents.last_mut() else {
return Err(PrototypeError::InvalidUnitHierarchy(format!(
"root tree consumed {cursor} records, but {} were encoded",
records.len()
)));
};
let parent_node_index = *parent_node_index;
*remaining_children -= 1;
let record_index = cursor;
let record = &records[record_index];
let child_count = usize::try_from(record.immediate_child_count).map_err(|_| {
PrototypeError::InvalidUnitHierarchy(format!(
"record {record_index} child count does not fit usize"
))
})?;
let node_index = nodes.len();
nodes.push(UnitComponentTreeNode {
record_index,
parent_index: Some(nodes[parent_node_index].record_index),
children: Vec::new(),
});
nodes[parent_node_index].children.push(node_index);
cursor += 1;
open_parents.push((node_index, child_count));
}
while open_parents
.last()
.is_some_and(|(_, remaining_children)| *remaining_children == 0)
{
open_parents.pop();
}
if !open_parents.is_empty() {
return Err(PrototypeError::InvalidUnitHierarchy(format!(
"child count reached record {cursor}, but only {} records exist",
records.len()
)));
}
Ok(nodes)
}
/// Decodes a mission unit DAT binding.
///
/// # Errors
@@ -1029,7 +1141,7 @@ fn resolve_archive_model(
let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else {
return Ok(None);
};
Ok(Some(effective(model_key.clone(), mesh, source)))
Ok(Some(effective(model_key.clone(), mesh, source, None, None)))
}
fn resolve_objects_registry_model(
@@ -1037,17 +1149,33 @@ fn resolve_objects_registry_model(
registry_archive: &NormalizedPath,
object_key: &ResourceName,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
let source_type = match repository.open_archive(registry_archive) {
Ok(archive_id) => {
find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))?
.map(|(entry, _)| repository.entry_info(entry))
.transpose()?
.and_then(|info| info.key.type_id)
}
Err(ResourceError::MissingArchive { .. }) => return Ok(None),
Err(error) => return Err(error.into()),
};
let Some(refs) =
collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)?
else {
return Ok(None);
};
let control_resource = resolve_explicit_control_resource(object_key, &refs)?;
let mut missing_mesh_refs = Vec::new();
for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) {
if let Some(prototype) =
resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))?
{
if let Some(prototype) = resolve_object_ref_model(
repository,
object_key,
item,
cstr_bytes(&item.resource_raw),
source_type,
control_resource.clone(),
)? {
return Ok(Some(prototype));
}
missing_mesh_refs.push(describe_object_ref(item));
@@ -1064,7 +1192,9 @@ fn resolve_objects_registry_model(
key: PrototypeKey(object_key.clone()),
geometry: PrototypeGeometry::NonGeometric,
source: PrototypeSource::ObjectsRegistry,
source_type,
dependencies: Vec::new(),
control_resource,
}))
}
@@ -1130,6 +1260,8 @@ fn resolve_object_ref_model(
requested: &ResourceName,
item: &ObjectRefRecord,
model_name: &[u8],
source_type: Option<u32>,
control_resource: Option<ResourceKey>,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))?
@@ -1140,6 +1272,8 @@ fn resolve_object_ref_model(
requested.clone(),
mesh,
PrototypeSource::ObjectsRegistry,
source_type,
control_resource,
)))
}
@@ -1147,6 +1281,61 @@ fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool {
has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh")
}
fn is_explicit_control_ref(item: &ObjectRefRecord) -> bool {
has_extension_bytes(cstr_bytes(&item.resource_raw), b"ctl")
}
fn resolve_explicit_control_resource(
requested: &ResourceName,
refs: &[ObjectRefRecord],
) -> Result<Option<ResourceKey>, PrototypeError> {
let mut resolved = Vec::new();
for item in refs.iter().filter(|item| is_explicit_control_ref(item)) {
let archive =
normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
let name = resource_name(cstr_bytes(&item.resource_raw));
// CTLD is an optional pose sidecar. Keep the exact registry reference
// here, then let asset preparation treat a missing archive/entry as an
// absent override while still rejecting a present malformed payload.
let key = ResourceKey {
archive,
name,
type_id: None,
};
if !resolved
.iter()
.any(|existing| same_resource_key(existing, &key))
{
resolved.push(key);
}
}
match resolved.len() {
0 => Ok(None),
1 => Ok(resolved.pop()),
_ => Err(PrototypeError::Resource(ResourceError::Format(format!(
"prototype {} has ambiguous explicit control resources: {}",
String::from_utf8_lossy(&requested.0),
resolved
.iter()
.map(|key| format!(
"{}:{}",
key.archive.as_str(),
String::from_utf8_lossy(&key.name.0)
))
.collect::<Vec<_>>()
.join(", "),
)))),
}
}
fn same_resource_key(left: &ResourceKey, right: &ResourceKey) -> bool {
left.archive
.as_str()
.eq_ignore_ascii_case(right.archive.as_str())
&& eq_ignore_ascii_case(&left.name.0, &right.name.0)
&& left.type_id == right.type_id
}
fn describe_object_ref(item: &ObjectRefRecord) -> String {
format!(
"{}:{}",
@@ -1195,12 +1384,16 @@ fn effective(
requested: ResourceName,
mesh: ResourceKey,
source: PrototypeSource,
source_type: Option<u32>,
control_resource: Option<ResourceKey>,
) -> EffectivePrototype {
EffectivePrototype {
key: PrototypeKey(requested),
geometry: PrototypeGeometry::Mesh(mesh.clone()),
source,
source_type,
dependencies: vec![mesh],
control_resource,
}
}
@@ -1380,7 +1573,7 @@ mod tests {
assert_eq!(record.parent_or_link, -7);
assert_eq!(&record.description_raw[..description.len()], description);
assert_eq!(record.tail0, 0x1122_3344);
assert_eq!(record.tail1, 0x5566_7788);
assert_eq!(record.immediate_child_count, 0x5566_7788);
}
#[test]
@@ -1406,6 +1599,88 @@ mod tests {
assert_eq!(unit.records[0].parent_or_link, 12);
}
#[test]
fn unit_component_tree_follows_preorder_child_counts_and_keeps_mount_links() {
let mut bytes = build_unit_dat(&[
(b"objects.rlb".as_slice(), b"root".as_slice()),
(b"objects.rlb".as_slice(), b"child".as_slice()),
(b"objects.rlb".as_slice(), b"grandchild".as_slice()),
]);
bytes[8 + 68..8 + 72].copy_from_slice(&(-1_i32).to_le_bytes());
bytes[8 + 108..8 + 112].copy_from_slice(&1_u32.to_le_bytes());
bytes[8 + 112 + 68..8 + 112 + 72].copy_from_slice(&7_i32.to_le_bytes());
bytes[8 + 112 + 108..8 + 112 + 112].copy_from_slice(&1_u32.to_le_bytes());
bytes[8 + 224 + 68..8 + 224 + 72].copy_from_slice(&3_i32.to_le_bytes());
let unit = decode_unit_dat(&bytes).expect("unit");
let tree = unit_component_tree(&unit.records).expect("preorder tree");
assert_eq!(tree.len(), 3);
assert_eq!(tree[0].record_index, 0);
assert_eq!(tree[0].parent_index, None);
assert_eq!(tree[0].children, vec![1]);
assert_eq!(tree[1].parent_index, Some(0));
assert_eq!(tree[1].children, vec![2]);
assert_eq!(tree[2].parent_index, Some(1));
assert_eq!(tree[2].children, Vec::<usize>::new());
assert_eq!(unit.records[1].parent_or_link, 7);
assert_eq!(unit.records[2].parent_or_link, 3);
}
#[test]
fn unit_component_tree_handles_deep_preorder_chains_iteratively() {
const DEPTH: usize = 32_768;
let records: Vec<_> = (0..DEPTH)
.map(|index| UnitComponentRecord {
archive_raw: [0; 32],
resource_raw: [0; 32],
kind: 0,
parent_or_link: 0,
description_raw: [0; 32],
tail0: 0,
immediate_child_count: u32::from(index + 1 < DEPTH),
})
.collect();
let tree = unit_component_tree(&records).expect("deep preorder chain");
assert_eq!(tree.len(), DEPTH);
assert_eq!(tree[0].parent_index, None);
assert_eq!(tree[0].children, vec![1]);
assert_eq!(tree[DEPTH / 2].parent_index, Some(DEPTH / 2 - 1));
assert_eq!(tree[DEPTH - 2].children, vec![DEPTH - 1]);
assert_eq!(tree[DEPTH - 1].parent_index, Some(DEPTH - 2));
assert!(tree[DEPTH - 1].children.is_empty());
}
#[test]
fn unit_component_tree_rejects_missing_child_records() {
let bytes = build_unit_dat(&[
(b"objects.rlb".as_slice(), b"root".as_slice()),
(b"objects.rlb".as_slice(), b"child".as_slice()),
]);
let mut unit = decode_unit_dat(&bytes).expect("unit");
unit.records[0].immediate_child_count = 2;
assert!(matches!(
unit_component_tree(&unit.records),
Err(PrototypeError::InvalidUnitHierarchy(message))
if message.contains("child count reached record 2")
));
}
#[test]
fn unit_component_tree_rejects_unconsumed_preorder_records() {
let bytes = build_unit_dat(&[
(b"objects.rlb".as_slice(), b"root".as_slice()),
(b"objects.rlb".as_slice(), b"child".as_slice()),
]);
let unit = decode_unit_dat(&bytes).expect("unit");
assert!(matches!(
unit_component_tree(&unit.records),
Err(PrototypeError::InvalidUnitHierarchy(_))
));
}
#[test]
fn resolves_synthetic_objects_registry_model() {
let mut vfs = MemoryVfs::default();
@@ -1436,6 +1711,7 @@ mod tests {
.expect("prototype");
assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry);
assert!(resolved.control_resource.is_none());
let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
panic!("expected mesh");
};
@@ -1855,10 +2131,10 @@ mod tests {
build_nres(&[
(
b"parent_proto".as_slice(),
build_object_refs(&[(
b"static.rlb".as_slice(),
b"parent_proto.msh".as_slice(),
)])
build_object_refs(&[
(b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()),
(b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
])
.as_slice(),
),
(
@@ -1866,6 +2142,7 @@ mod tests {
build_object_refs(&[
(b"objects.rlb".as_slice(), b"parent_proto".as_slice()),
(b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()),
(b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
])
.as_slice(),
),
@@ -1881,7 +2158,13 @@ mod tests {
);
vfs.insert(
fortif_path,
Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()),
Arc::from(
build_nres(&[
(b"child_proto.bas".as_slice(), b"base"),
(b"parent_proto.ctl".as_slice(), b"control"),
])
.into_boxed_slice(),
),
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
@@ -1895,6 +2178,108 @@ mod tests {
};
assert_eq!(mesh.archive.as_str(), "static.rlb");
assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh"));
let control = resolved.control_resource.expect("inherited explicit CTL");
assert_eq!(control.archive.as_str(), "fortif.rlb");
assert!(control.name.0.eq_ignore_ascii_case(b"parent_proto.ctl"));
}
#[test]
fn objects_registry_retains_missing_explicit_control_metadata() {
let mut vfs = MemoryVfs::default();
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
let static_path = resource_archive_path(b"static.rlb").expect("static path");
let mesh = minimal_msh_payload();
vfs.insert(
objects_path,
Arc::from(
build_nres(&[(
b"optional_control".as_slice(),
build_object_refs(&[
(b"static.rlb".as_slice(), b"optional_control.msh".as_slice()),
(
b"missing.rlb".as_slice(),
b"optional_control.ctl".as_slice(),
),
])
.as_slice(),
)])
.into_boxed_slice(),
),
);
vfs.insert(
static_path,
Arc::from(
build_nres(&[(b"optional_control.msh".as_slice(), mesh.as_slice())])
.into_boxed_slice(),
),
);
let vfs = Arc::new(vfs);
let repository = CachedResourceRepository::new(vfs.clone());
let resolved = resolve_prototype_single(
&repository,
vfs.as_ref(),
&resource_name(b"optional_control"),
)
.expect("resolve despite absent optional CTL")
.expect("prototype");
let control = resolved
.control_resource
.expect("preserve explicit missing CTL reference");
assert_eq!(control.archive.as_str(), "missing.rlb");
assert_eq!(control.name, resource_name(b"optional_control.ctl"));
assert_eq!(control.type_id, None);
}
#[test]
fn objects_registry_rejects_distinct_explicit_controls() {
let mut vfs = MemoryVfs::default();
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
let static_path = resource_archive_path(b"static.rlb").expect("static path");
let controls_path = resource_archive_path(b"controls.rlb").expect("controls path");
let mesh = minimal_msh_payload();
vfs.insert(
objects_path,
Arc::from(
build_nres(&[(
b"prototype".as_slice(),
build_object_refs(&[
(b"static.rlb".as_slice(), b"prototype.msh".as_slice()),
(b"controls.rlb".as_slice(), b"first.ctl".as_slice()),
(b"controls.rlb".as_slice(), b"second.ctl".as_slice()),
])
.as_slice(),
)])
.into_boxed_slice(),
),
);
vfs.insert(
static_path,
Arc::from(
build_nres(&[(b"prototype.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
),
);
vfs.insert(
controls_path,
Arc::from(
build_nres(&[
(b"first.ctl".as_slice(), b"first control"),
(b"second.ctl".as_slice(), b"second control"),
])
.into_boxed_slice(),
),
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
let error = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"prototype"))
.expect_err("distinct explicit controls are ambiguous");
assert!(matches!(
error,
PrototypeError::Resource(ResourceError::Format(message))
if message.contains("ambiguous explicit control resources")
));
}
#[test]
+85 -3
View File
@@ -319,7 +319,7 @@ impl LegacyIron3dEulerTransform {
/// Fixed-function blend behaviour represented without a graphics API type.
///
/// This is a compatibility contract, not yet a decoded MAT0 mapping.
/// This is the compatibility contract for the native MAT0 pipeline category.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyBlendMode {
/// Do not blend the fragment with the existing colour.
@@ -327,6 +327,14 @@ pub enum LegacyBlendMode {
Opaque,
/// Blend using source alpha.
SourceAlpha,
/// Add the source-alpha-scaled fragment to the existing colour.
Additive,
/// Replace the source factor with zero and use source colour as the
/// destination factor.
ZeroSourceColor,
/// Multiply the source by destination colour and destination by source
/// colour, matching the native two-colour blend mode.
DestColorSourceColor,
}
/// Depth-buffer behaviour represented without a graphics API type.
@@ -369,10 +377,36 @@ pub struct LegacyPipelineState {
pub alpha_test: bool,
}
impl LegacyPipelineState {
/// Applies the native MAT0 pipeline category while retaining the caller's
/// depth and cull state.
///
/// Categories 0..4 are the complete table recovered from the native
/// material path. The native table enables `GREATER_EQUAL` alpha testing
/// for categories 1..4; the alpha reference remains per draw range.
#[must_use]
pub const fn with_material_category(self, category: u8) -> Option<Self> {
let (blend, alpha_test) = match category {
0 => (LegacyBlendMode::Opaque, false),
1 => (LegacyBlendMode::SourceAlpha, true),
2 => (LegacyBlendMode::Additive, true),
3 => (LegacyBlendMode::ZeroSourceColor, true),
4 => (LegacyBlendMode::DestColorSourceColor, true),
_ => return None,
};
Some(Self {
blend,
alpha_test,
..self
})
}
}
/// Canonical, backend-neutral key for a graphics-pipeline variant.
///
/// The value is explicitly packed rather than hashed, so captures and caches
/// remain stable across processes and Rust toolchain updates.
/// remain stable across processes and Rust toolchain updates. Three bits are
/// reserved for the five native material blend modes.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PipelineKey(u8);
@@ -389,6 +423,9 @@ impl From<LegacyPipelineState> for PipelineKey {
let blend = match state.blend {
LegacyBlendMode::Opaque => 0,
LegacyBlendMode::SourceAlpha => 1,
LegacyBlendMode::Additive => 2,
LegacyBlendMode::ZeroSourceColor => 3,
LegacyBlendMode::DestColorSourceColor => 4,
};
let depth = match state.depth {
LegacyDepthMode::Disabled => 0,
@@ -400,7 +437,7 @@ impl From<LegacyPipelineState> for PipelineKey {
LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2,
};
Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5))
Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
}
}
@@ -549,4 +586,49 @@ mod tests {
None
);
}
#[test]
fn native_material_categories_preserve_depth_and_cull_state() {
let base = LegacyPipelineState {
depth: LegacyDepthMode::TestWrite,
cull: LegacyCullMode::BackFace,
..LegacyPipelineState::default()
};
let expected = [
(LegacyBlendMode::Opaque, false),
(LegacyBlendMode::SourceAlpha, true),
(LegacyBlendMode::Additive, true),
(LegacyBlendMode::ZeroSourceColor, true),
(LegacyBlendMode::DestColorSourceColor, true),
];
for (category, (blend, alpha_test)) in expected.into_iter().enumerate() {
let state = base
.with_material_category(category as u8)
.expect("native category is represented");
assert_eq!(state.blend, blend);
assert_eq!(state.alpha_test, alpha_test);
assert_eq!(state.depth, base.depth);
assert_eq!(state.cull, base.cull);
}
assert_eq!(base.with_material_category(5), None);
}
#[test]
fn native_material_categories_have_distinct_pipeline_keys() {
let base = LegacyPipelineState {
depth: LegacyDepthMode::TestWrite,
..LegacyPipelineState::default()
};
let keys: Vec<_> = (0..=4)
.map(|category| {
PipelineKey::from(
base.with_material_category(category)
.expect("native category is represented"),
)
})
.collect();
for (index, key) in keys.iter().enumerate() {
assert!(keys[index + 1..].iter().all(|other| other != key));
}
}
}
+5 -1
View File
@@ -1761,7 +1761,7 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_load_all_mission_foundations() {
fn licensed_corpus_part1_loads_all_mission_foundations() {
let part1 = load_all(&licensed_root("IS"));
assert_eq!(part1.missions, 29);
assert_eq!(part1.paths, 34);
@@ -1781,7 +1781,11 @@ mod tests {
assert_eq!(part1.material_slots, part1.material_resolved);
assert_eq!(part1.texture_requests, part1.texture_resolved);
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part2_loads_all_mission_foundations() {
let part2 = load_all(&licensed_root("IS2"));
assert_eq!(part2.missions, 31);
assert_eq!(part2.paths, 61);
+108 -41
View File
@@ -43,16 +43,18 @@ pub struct CompactSurfaceMask(pub u16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8);
/// The two positional material-table selectors packed into a terrain face tag.
/// The base and overlay selectors packed into a terrain face tag.
///
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
/// no-selection sentinel. The low byte selects from `Land2.wea`.
/// Both selectors address the map-local `Land1.wea` table. The low byte is
/// always the base row; the high byte is an optional overlay row and uses
/// `0xff` as its no-overlay sentinel. `Land2.wea` is a separate detail bank
/// selected by the corresponding detail path.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers {
/// Optional high-byte selector for `Land1.wea`.
pub land1_selector: Option<u8>,
/// Low-byte selector for `Land2.wea`.
pub land2_selector: u8,
/// Low-byte base selector for `Land1.wea`.
pub base_selector: u8,
/// Optional high-byte overlay selector for `Land1.wea`.
pub overlay_selector: Option<u8>,
}
/// One `World3D` material-manager lookup key.
@@ -79,31 +81,56 @@ impl TerrainMaterialLayers {
/// Constructs the decoded pair from its on-disk packed tag.
#[must_use]
pub const fn from_packed_tag(material_tag: u16) -> Self {
let [land2_selector, land1] = material_tag.to_le_bytes();
let [base_selector, overlay] = material_tag.to_le_bytes();
Self {
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
land2_selector,
base_selector,
overlay_selector: if overlay == u8::MAX {
None
} else {
Some(overlay)
},
}
}
/// Returns the table-zero `Land1.wea` selection when it exists.
/// Returns the base selection from `Land1.wea`.
#[must_use]
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
self.land1_selector
pub fn base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(self.base_selector),
}
}
/// Returns the optional overlay selection from `Land1.wea`.
#[must_use]
pub fn overlay_selection(self) -> Option<TerrainMaterialSelection> {
self.overlay_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
})
}
/// Returns the table-one `Land2.wea` selection.
/// Returns the detail selection corresponding to the base row in
/// `Land2.wea`.
#[must_use]
pub fn land2_selection(self) -> TerrainMaterialSelection {
pub fn detail_base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(self.land2_selector),
material_index: u16::from(self.base_selector),
}
}
/// Returns the optional detail selection corresponding to the overlay row
/// in `Land2.wea`.
#[must_use]
pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> {
self.overlay_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(material_index),
})
}
}
/// Terrain face with 28-byte source layout.
@@ -231,7 +258,7 @@ pub struct LandMeshDocument {
/// Packed normals from type 4.
pub normals: Vec<[i8; 4]>,
/// Packed UV from type 5.
pub uv0: Vec<[i16; 2]>,
pub uv0: Vec<[u16; 2]>,
/// Type 11 accelerator words.
pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words.
@@ -631,7 +658,7 @@ pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainF
validate_slots(nres, slots)?;
let positions = parse_positions(nres, positions)?;
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
let uv0 = parse_u16x2_stream(nres, TYPE_UV0, uv0)?;
let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
@@ -683,6 +710,11 @@ pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFo
let payload = nres.payload(entry.id())?;
let areal_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
checked_count_bytes(
u64::from(meta.attr1),
u64::try_from(AREAL_PREFIX_SIZE).map_err(|_| TerrainFormatError::IntegerOverflow)?,
payload.len() as u64,
)?;
let mut cursor = Cursor::new(payload);
let mut areals = Vec::with_capacity(areal_count);
for area_index in 0..areal_count {
@@ -1072,11 +1104,11 @@ fn parse_i8x4_stream(
.collect())
}
fn parse_i16x2_stream(
fn parse_u16x2_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
) -> Result<Vec<[u16; 2]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize {
type_id,
@@ -1088,7 +1120,7 @@ fn parse_i16x2_stream(
validate_stream(nres, type_id, 4, count)?;
let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(4) {
out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]);
out.push([read_u16(chunk, 0)?, read_u16(chunk, 2)?]);
}
Ok(out)
}
@@ -1311,6 +1343,7 @@ fn parse_areal_grid(
if cell_count == 0 {
return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
}
checked_count_bytes(u64::from(cell_count), 2, cursor.remaining() as u64)?;
let cell_count_usize =
usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut cells = Vec::with_capacity(cell_count_usize);
@@ -1364,13 +1397,6 @@ fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, TerrainFormatError> {
Ok(u16::from_le_bytes([raw[0], raw[1]]))
}
fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 2)
.ok_or(TerrainFormatError::IntegerOverflow)?;
Ok(i16::from_le_bytes([raw[0], raw[1]]))
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 4)
@@ -1574,7 +1600,7 @@ mod tests {
}
#[test]
fn terrain_material_tag_decodes_two_sidecar_selectors() {
fn terrain_material_tag_decodes_base_overlay_and_detail_selectors() {
let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1583,24 +1609,40 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: Some(1),
land2_selector: 2,
base_selector: 2,
overlay_selector: Some(1),
}
);
assert_eq!(
document.faces[0].material_layers().land1_selection(),
Some(TerrainMaterialSelection {
document.faces[0].material_layers().base_selection(),
TerrainMaterialSelection {
table_index: 0,
material_index: 1,
})
material_index: 2,
}
);
assert_eq!(
document.faces[0]
.material_layers()
.land2_selection()
.overlay_selection()
.expect("overlay")
.phase_key(),
0x0000_0001
);
assert_eq!(
document.faces[0]
.material_layers()
.detail_base_selection()
.phase_key(),
0x0001_0002
);
assert_eq!(
document.faces[0]
.material_layers()
.detail_overlay_selection()
.expect("detail overlay")
.phase_key(),
0x0001_0001
);
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1608,18 +1650,28 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: None,
land2_selector: 3,
base_selector: 3,
overlay_selector: None,
}
);
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
assert_eq!(
document.faces[0].material_layers().land2_selection(),
document.faces[0].material_layers().base_selection(),
TerrainMaterialSelection {
table_index: 1,
table_index: 0,
material_index: 3,
}
);
assert_eq!(
document.faces[0].material_layers().overlay_selection(),
None
);
assert_eq!(
document.faces[0]
.material_layers()
.detail_base_selection()
.phase_key(),
0x0001_0003
);
}
#[test]
@@ -1637,6 +1689,21 @@ mod tests {
assert_eq!(document.grid.compact_cells, [0x0040_0000]);
}
#[test]
fn land_map_bounds_area_and_grid_reservations_by_payload() {
let empty = build_nres(&[entry(TYPE_AREAL_MAP, u32::MAX, 0, &[])]);
let nres = decode_nres(&empty).expect("nres");
assert!(decode_land_map(&nres).is_err());
let mut payload = Vec::new();
push_areal_prefix(&mut payload, 0, 0);
push_u32(&mut payload, 65_535);
push_u32(&mut payload, 65_535);
let bytes = build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]);
let nres = decode_nres(&bytes).expect("nres");
assert!(decode_land_map(&nres).is_err());
}
#[test]
fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() {
let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
+70 -3
View File
@@ -1,14 +1,16 @@
#![forbid(unsafe_code)]
//! Validated terrain runtime queries using XY ground coordinates and Z height.
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
use fparkan_terrain_format::{LandMapDocument, LandMeshDocument};
use std::collections::VecDeque;
/// Terrain material-selector contract preserved from the decoded map mesh.
///
/// Applications access this semantic terrain API through this terrain crate
/// rather than taking a direct dependency on the binary-format parser.
pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
pub use fparkan_terrain_format::{
FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection,
};
/// Terrain world.
#[derive(Clone, Debug, Default)]
@@ -141,6 +143,21 @@ pub trait SurfaceQuery {
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError>;
/// Raycast while requiring every bit in `include` and rejecting every
/// face carrying a bit in `exclude`.
///
/// The native landscape query has both predicates. The legacy
/// [`Self::raycast`] shorthand keeps its historical include-only form;
/// callers that model a native receiver or visibility test should use
/// this method so exclusion flags cannot be mistaken for inclusion bits.
fn raycast_excluding(
&self,
origin: [f32; 3],
direction: [f32; 3],
include: FullSurfaceMask,
exclude: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError>;
}
/// Navigation query.
@@ -404,6 +421,28 @@ impl SurfaceQuery for TerrainWorld {
origin: [f32; 3],
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
self.raycast_filtered(origin, direction, mask, FullSurfaceMask(0))
}
fn raycast_excluding(
&self,
origin: [f32; 3],
direction: [f32; 3],
include: FullSurfaceMask,
exclude: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
self.raycast_filtered(origin, direction, include, exclude)
}
}
impl TerrainWorld {
fn raycast_filtered(
&self,
origin: [f32; 3],
direction: [f32; 3],
include: FullSurfaceMask,
exclude: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
@@ -413,7 +452,10 @@ impl SurfaceQuery for TerrainWorld {
let mut best: Option<SurfaceHit> = None;
for index in candidates {
let triangle = &self.surfaces[index];
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
if include.0 != 0 && triangle.mask.0 & include.0 != include.0 {
continue;
}
if triangle.mask.0 & exclude.0 != 0 {
continue;
}
let Some(distance) = triangle.raycast(origin, direction) else {
@@ -1032,6 +1074,31 @@ mod tests {
.expect("raycast"),
None
);
// Native include masks require every requested bit, while exclusion
// masks reject any matching face bit.
assert_eq!(
world
.raycast_excluding(
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0x0000_0003),
FullSurfaceMask(0),
)
.expect("raycast include all bits"),
None
);
assert_eq!(
world
.raycast_excluding(
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0),
FullSurfaceMask(0x0000_0001),
)
.expect("raycast exclusion"),
None
);
}
#[test]
+19 -36
View File
@@ -10,7 +10,7 @@ messages, teleports, задачи, research и campaign transitions. Точки
и `briefing.cfg`.
`.scr` — binary package с version checks, symbol/event sections и offsets;
полная opcode grammar не доказана. Его внешний framing теперь читает
полная opcode grammar не доказана. Его внешний framing читает
`fparkan-script`: первый little-endian `u32` является числом required opcode
handlers, второй — числом event records. Каждый event хранит `name_len`,
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
@@ -108,24 +108,21 @@ unsupported result, а не «примерный» game command.
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
находится в отдельном позднем update path. Воспроизводимый read-only extractor:
`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
находится в отдельном позднем update path.
Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr`
имеются 6 087 instruction records, из них 3 992 sentinel; самый частый
non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266`
читает первые два reference words активной instruction, разрешает каждый
через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с
тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
Во всех 58 GOG `.scr` имеются 6 087 instruction records, включая 3 992
sentinel. Самый частый non-sentinel selector — `Handler(30)`, 246 records.
Его VA `0x1000c266` читает первые два reference words активной instruction,
разрешает каждый через varset (`0x10002d30` и `0x10013570`) и вызывает внешний
callback с тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
`1`; предметная семантика этих modes ещё не доказана. В частности, это пока
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
text-to-varset resolver расположен за wrapper `0x10011ea0` в
`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`,
`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`.
`0x100174a0`.
Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый
`0x100174a0` добавляет каждый
recognized source declaration в encounter order как 48-byte record; GOG shared
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
@@ -134,10 +131,9 @@ capture. Полный GOG scan всех 246 Handler(30) instructions показ
operand references: все 492 in-range и указывают на `DWORD`. Поэтому
`VarSet::resolve_handler30` уже materializes точный opaque callback command
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
out-of-range и incomplete instructions вместо silent coercion. Extractors:
`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`.
out-of-range и incomplete instructions вместо silent coercion.
Следующий static pass закрывает equality/update policy. Identity ровно равна
Identity ровно равна
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
@@ -147,7 +143,7 @@ slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
не назначает игровых имён и не делает event lookup за original VM.
На границе mission runtime выбранный TMA clan `first_resource` теперь
На границе mission runtime выбранный TMA clan `first_resource`
материализуется как отдельный `MissionScriptBundle`: loader нормализует
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
package вместе с clan provenance. Это именно wiring входных данных, не VM
@@ -206,9 +202,9 @@ contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`;
the Rust loader reports `script_init_states=2` and `script_varset_states=2`.
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
`ai.dll + 0x55398` for `n <= 63`. The read-only
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values
`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields
`ai.dll + 0x55398` for `n <= 63`. A read-only capture of the running GOG
AutoDemo process observed `(500, 752, 0)` for entry 0 and `(728, 449, 1)` for
entry 1 at fields
`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
profile.
@@ -216,12 +212,7 @@ The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
converted first two words and the third raw word, produces three typed writes,
and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
binds it to the proven creation/anchor path above; it does not guess the
remaining script event semantics. The associated Ghidra scripts are
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
The creation and conversion boundaries are reproducible with
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
`ExportAiFtol.java`.
remaining script event semantics.
### Runtime Handler(30) operand binding
@@ -256,12 +247,7 @@ proves the lookup and one-shot/repeat split, not the UI/message subject or the
semantics of either resource ID; Rust therefore retains command `1` as
`Unhandled`.
Reproduce the callback and the command-one consumers with
`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`,
`ExportIron3dAiCallbackCommand1.java`, and
`ExportIron3dAiCallbackCommand1Dispatch.java`.
Runtime now applies only this recovered branch as
Runtime applies only this branch as
`apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission
to `Failed`, `(0, 0, 1)` transitions it to `Completed`, and a repeated target
state is a no-op just as the Iron3D guards require. The effect is deliberately
@@ -284,9 +270,7 @@ opaque callback slots; state `3` does the same except word `3` is preserved.
Both then write `+0x18`. Every other state simply writes the state word.
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
live record index, resolved state, and explicit reset kind. It does not invent
the table owner, the pre-reset helper, or callback semantics. Reproduce the
evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and
`ExportAiVmHandler8Transitions.java`.
the table owner, the pre-reset helper, or callback semantics.
### Handler(15): typed target-call boundary
@@ -314,8 +298,7 @@ with that record and the third word. Its zero/non-zero result becomes
return value remain unproven. Accordingly `VarSet::resolve_handler15` only
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
it never executes the opaque target call. Missing, out-of-range, wrong-type,
and unobserved-mode inputs are explicit errors. Reproduce the static evidence
with `tools/ghidra/ExportAiVmHandler15.java`.
and unobserved-mode inputs are explicit errors.
## Готовность
+7
View File
@@ -18,6 +18,13 @@ FParkan воспроизводит его работу на Rust и Vulkan. На
7. [Работа над движком](tomes/07-implementation.md): код, тесты и проверка результата.
8. [Устройство оригинальной программы](tomes/08-evidence.md): DLL, адреса и конфигурация.
В справочнике можно быстро сверить [NRes](reference/nres.md),
[RsLi](reference/rsli.md), [TMA](reference/tma.md), [MSH](reference/msh.md),
[материалы](reference/materials.md), [Texm](reference/texm.md),
[кадр рендера](reference/render-frame.md), [атмосферу и небо](reference/atmosphere.md)
и [эффекты окружения](reference/environment-effects.md). Таблица DLL находится
в [описании оригинальных модулей](reference/original-binaries.md).
В [глоссарии](appendices/glossary.md) собраны термины, а в
[открытых вопросах](appendices/knowledge-boundaries.md) — ещё не восстановленное
поведение. Описание алгоритма оригинала и возможности текущего приложения
+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
#pragma pack(push, 1)
struct Mat0PrefixV4Plus {
struct Mat0Header {
uint16_t phase_count;
uint16_t animation_block_count;
uint8_t metadata_a;
uint8_t metadata_b;
uint32_t metadata_c_raw;
uint32_t metadata_d_raw;
};
struct Phase34 {
@@ -54,8 +50,78 @@ struct Phase34 {
#pragma pack(pop)
```
Versioned fields читаются только если версия их содержит. Для старых версий
используются runtime defaults, а raw values сохраняются.
`attr1` читается из NRes entry metadata и сохраняется вместе с разрешённым
материалом. Это runtime flags материала; его нельзя путать с `attr2`, который
задаёт версию payload. Versioned prefix bytes читаются только если версия их
содержит, а raw values сохраняются для последующих слоёв.
### Pipeline category
Call site извлекает native category как `(attr1 >> 2) & 0xF` и передаёт её в
`LegacyPipelineState::with_material_category`. Таблица содержит пять
подтверждённых категорий; helper возвращает `None` для остальных значений и не
меняет уже выбранные depth и cull state.
| category | source/destination colour factors | alpha test |
| ---: | --- | --- |
| 0 | `ONE / ZERO`, blending disabled | disabled |
| 1 | `SRC_ALPHA / INV_SRC_ALPHA` | enabled, `GREATER_EQUAL` |
| 2 | `SRC_ALPHA / ONE` | enabled, `GREATER_EQUAL` |
| 3 | `ZERO / SRC_COLOR` | enabled, `GREATER_EQUAL` |
| 4 | `DEST_COLOR / SRC_COLOR` | enabled, `GREATER_EQUAL` |
The mapping comes from the native five render-state pairs selected by the
material category. Alpha reference remains draw-range data, while the Vulkan
pipeline key includes the blend mode, depth mode, cull mode and alpha-test
variant so ranges with different native state do not share a pipeline.
Каждая phase занимает 34 байта: 18 parameter bytes и 16-byte C string имени
текстуры. Loader переводит параметры в коэффициенты так:
| bytes | runtime value |
| --- | --- |
| `p0..p2` | additive RGB, `p / 255` |
| `p3` | opacity, `p * 0.01` |
| `p4..p7` | directional values, `p / 255` |
| `p8..p11` | specular values, `p / 255` |
| `p12..p15` | extra values, `p / 255` |
| `p16` | integer power |
| `p17` | signed TEXM page index |
Texture name и page index выбираются из текущей phase. Они не смешиваются с
соседней phase.
## Animation
После phase table идут `animation_block_count` плотных блоков. Каждый блок имеет
`u32 header_raw`, `u16 key_count`, затем `key_count` записей по три `u16`:
```text
u32 header_raw
u16 key_count
repeat key_count:
u16 phase_index
u16 end_time_ms
u16 raw_k2
```
`header_raw & 7` задаёт режим: `0` loop, `1` ping-pong, `2` clamp, `3`
random-per-query. Значения `4..7` сохраняются как unknown. `header_raw >> 3`
является interpolation mask: bits `1`, `2`, `4`, `8` смешивают группы
`p0..p2`, `p4..p6`, `p8..p10`, `p12..p14`, а bit `16` смешивает `p3`.
Компоненты `p7`, `p11`, `p15`, `p16` и `p17` копируются из текущей phase.
Время для блока вычисляется как `clock_ms - wear_row_start_ms` с wrapping
`u32` subtraction. Для loop берётся остаток от последнего `end_time_ms`, для
ping-pong нечётный цикл идёт в обратном направлении, clamp после duration
выбирает последнюю phase, а random-per-query использует переданное случайное
значение по модулю duration. Ключ выбирается по интервалу
`previous_end <= local_time < end_time`; следующий ключ циклически замыкается на
первый. На ping-pong turnaround и clamp после duration native routine проходит
общий float interpolation path, включая unsigned subtraction endpoint arithmetic;
это может дать extrapolated coefficients при ненулевой mask. Переданный в
sampler `animation_block_index` уже выбран вызывающим runtime из block table;
packed WEAR handle и номер строки WEAR являются отдельными значениями.
## Fallback
+79 -21
View File
@@ -19,7 +19,7 @@ type 10 strings and node names
type 13 Batch20 records
type 15 auxiliary stream
type 17 auxiliary data
type 18 rare stream
type 18 packed UV1 (optional secondary coordinates)
type 19 animation frame map
type 20 rare auxiliary table
```
@@ -40,23 +40,77 @@ struct Node38 {
};
```
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
отсутствие геометрии для комбинации LOD/group.
В исходном layout это 3 состояния по 5 LOD: `slot_index[state * 5 + lod]`.
Публичные Rust-обёртки пока сохраняют исторические имена `Lod` и `Group`,
поэтому в `selected_slot` аргумент `Lod` задаёт native state (0..2), а
`Group` задаёт native LOD (0..4). `0xFFFF` означает отсутствие геометрии для
комбинации state/LOD.
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
Validated `ModelAsset` сохраняет decoded type 8 keys и type 19 map как
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
иначе это parent index, обязательно меньший индекса child. Этот контракт
подтверждён на тестах анимации с оригинальными ресурсами обеих частей и защищён fallback-ом:
модель с нарушенным порядком не получает придуманную hierarchy.
а `node38_fallback_hierarchy` собирает parent-before-child hierarchy.
`node38_sampled_hierarchy` использует type 19 map в пределах объявленного
числа кадров и интерполирует соседние type 8 keys; при отсутствии usable map
или выходе за frame count используется fallback key. Для покомпонентного
sampling `node38_sampled_hierarchy_at_times` принимает отдельный optional float
time для каждого узла: `Some(time)` выбирает type 19 frame по округлению
`time - 0.5` к ближайшему целому с ties-to-even, затем интерполирует выбранный
type 8 key и его следующий key в исходный float time; `None` использует
fallback key. Значения должны быть конечными и неотрицательными. Это
portable-поведение при default-nearest rounding; полного совпадения со всеми
режимами округления x87 оно не заявляет. `parent_or_link == 0xFFFF`
означает root, иначе это parent index, который должен быть меньше индекса
child. Модель с нарушенным порядком не получает придуманную hierarchy.
В legacy-camera static preview стандартный узел уже получает свой fallback pose
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
затем translation суммируется, а rotations умножаются; после полученной global
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
намеренно дублируется на draw-range узла, потому что один source vertex может
быть нарисован разными node poses. Это static fallback hierarchy, а не полная
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
Type 8 записывает quaternion в порядке WXYZ, после decode `AnimKey24` хранит
его как XYZW. Native AniMesh `+0x12560` строит local matrix через NGI32
`g_FastProc + 0x38`; scalar implementation выдаёт transpose обычной active
`Pose` basis (`M01=2(xy+zw)`, `M02=2(xz-yw)`, `M10=2(xy-zw)`). Поэтому путь
Node38 MSH сопрягает source-local quaternion ровно один раз после sampling и
до hierarchy composition (меняет знаки компонент `x`, `y`, `z`). Для fallback
poses применяется та же граница. Общий animation decoder и другие форматы
анимации не изменяются.
В legacy-camera static preview без `--static-animation-frame <u16>` используются
сохранённые начальные значения из явной ссылки каждого prototype на `.ctl`.
Узлы без control binding получают time `0`. Для каждой component instance
обрабатываются её CTLD bindings, поэтому общий deduplicated MSH не смешивает
controls разных компонентов. Строки идут в source order, и последняя включённая
строка для одного узла заменяет предыдущую. Flag `0x01` выполняет один wrap
значения blend на единицу, иначе значение ограничивается `[0, 1]`; flag `0x02`
инвертирует результат (`1 - blend`), а строки с `0x04` пропускаются. Выбранный
float time равен `(1 - blend) * frame_a + blend * frame_b`. Если он выходит за
type 19 map, MSH sampler использует fallback key; если после одной wrap/clamp и
invert получается отрицательное или нечисловое время, preview завершится
понятной ошибкой. Явный `--static-animation-frame` остаётся global override
для всех компонентов. Parent pose поворачивает child translation, затем
translation суммируется, а rotations умножаются; после полученной global pose
применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия намеренно
дублируется на draw-range узла, потому что один source vertex может быть
нарисован разными node poses. Это только начальная статическая pose: она не
запускает игровой Control/AI и не обещает полного совпадения с runtime или
всеми x87 rounding modes.
## Vertex streams
Основные vertex streams имеют фиксированный source index. У type 3 `attr1`
задаёт количество вершин, а `attr3` — размер записи в байтах. Type 4
содержит четыре signed bytes нормали. Type 5 и optional type 18 содержат по
два little-endian `uint16` на вершину:
```c
struct PackedUv16x2 {
uint16_t u;
uint16_t v;
};
```
Type 5 — primary UV0, type 18 — secondary UV1 для lightmap/detail stage.
Для type 18 loader требует `attr3 == 4`, совпадение `attr1` и длины payload,
а также одинаковое число записей с type 3. Обе пары декодируются как
`packed / 1024.0`; renderer обращается к UV1 по тому же `source_index`, что и
к UV0. Отсутствующий type 18 означает, что secondary stage не имеет authored
координат.
## Slot and batch
@@ -82,16 +136,20 @@ Type 13 задаёт draw ranges:
#pragma pack(push, 1)
struct Batch20 {
uint16_t batch_flags;
uint16_t material_index;
uint16_t opaque4;
uint16_t opaque6;
uint16_t material_index_hi; // +0x02, native high selector/slot field
uint8_t material_index; // +0x04, WEAR material selector
uint8_t lightmap_index; // +0x05, 0xFF means no lightmap
uint16_t local_batch_index; // +0x06
uint16_t index_count;
uint32_t index_start;
uint16_t opaque14;
uint16_t vertex_count; // +0x0E
uint32_t base_vertex;
};
#pragma pack(pop)
```
Index check выполняется как `base_vertex + index < vertex_count` для всего
используемого slice.
Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
отдельно для native overrides. Loader проверяет, что
`index_start + index_count` остаётся внутри type 6, а каждый использованный
индекс через `base_vertex` попадает в position stream. `vertex_count` и
`local_batch_index` доступны renderer для исходного draw contract.
+8
View File
@@ -69,6 +69,14 @@ projection matrix contract.
## Parity risks
`VulkanStaticCamera::from_legacy_d3d7` keeps the D3D7 view and projection
reconstruction in `fparkan-render`, then negates the vertical projection term
once at the Vulkan adapter boundary. D3D7's top-down viewport places positive
NDC Y at the top; the Vulkan swapchain uses a positive viewport height, so the
adapter flips NDC Y while the shader preserves `gl_Position`. The generic
`from_row_major_view_projection` path remains unchanged; its callers supply
Vulkan-ready matrices, as the free-flight camera already does.
- x87 precision and rounding;
- scalar/SIMD `g_FastProc` differences;
- object, batch and transparent primitive order;
+55 -19
View File
@@ -359,9 +359,16 @@ local.set_translation(pose.position);
world[n] = world[parent(n)] * local;
```
Для parity особенно важны x87-compatible округление при выборе frame index и
порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле
границы.
Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа
на CTLD `.ctl`. Используются только включённые строки, а при повторе node ID
поздняя строка заменяет раннюю. Без `--static-animation-frame` preview применяет
эти настройки узлов; узлы без настройки остаются на кадре `0`. Явный
`--static-animation-frame N` переопределяет их и задаёт `N` всем узлам.
Результат — одна статическая поза, не обновление анимации, Control или AI.
MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему
целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий
key в исходном `time`; это portable-вариант native x87 conversion в default
rounding mode, без обещания совпадения при любом x87 control word.
Проверки animation data:
@@ -611,11 +618,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж
diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию.
Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и
роботов, ограничения максимального числа, detail level и smoothing. Доказаны
shadow manager/pass, настройки detail/smoothing/count и зависимость от
Terrain/CShade; полная формула projection geometry для каждого caster требует
dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по
именам, а не заменяются произвольными modern defaults.
роботов, ограничения максимального числа, detail level и smoothing. Реализованы
native visibility по шести Vulkan clip-плоскостям с радиусом sphere +100,
порядок кандидатов и лимит 20 до построения страниц, projected-size LOD,
directional/point light contribution, native page layout, raster, smoothing и
receiver projection с edge clipping. Unknown settings из `shade.cfg` читаются и
сохраняются по именам, а не заменяются произвольными modern defaults. Для native ray query
surface descriptor в `+0x10` требует наличия всех переданных bits, а descriptor
в `+0x14` исключает поверхности с любым переданным bit. Общий shadow query
использует full exclusion mask `0x2000`; actor four-ray path получает compact
mask `0x8008`, который landscape boundary разворачивает в full mask
`0x00200020`. Sun path передаёт full `0x20`, кодирует его как compact bit `8`
и затем получает тот же full bit после разворачивания. Hit record хранит в
`+0x20` квадрат евклидовой длины луча, поэтому native four-ray fade использует
`distance² * 1e-4`, а не линейную дальность.
Atmosphere manager создаёт world objects для фоновых и погодных явлений.
Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain
@@ -624,6 +640,9 @@ background sound и обязательные ссылки на lightning effect.
требует screen position и occlusion test, rain -- области рядом с observer,
sound -- listener. Их нельзя один раз запечь в terrain.
Sun occlusion начинается от точки `camera + 0.5 * normalize(sun - camera)`;
этот offset является частью native query, а не произвольным bias renderer-а.
RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок.
Даже правильный средний интервал не даёт повторяемый кадр, если random values
запрашиваются в другой последовательности.
@@ -677,11 +696,14 @@ settings ID сохраняются.
```text
opcode = command_word & 0xFF
enabled = (command_word >> 8) & 1
native_flag = (command_word >> 8) & 1
```
Bits 9-31 являются частью данных и сохраняются. Между командами нет
выравнивания. Размер команды, включая word:
`native_flag` передаётся созданному native command object как режим/feature
flag и не отключает команду. Например, реальный `env_lightning` использует
`native_flag == 0` для opcode 3, opcode 1 и opcode 2, и все три команды
исполняются. Bits 9-31 являются частью данных и сохраняются. Между командами
нет выравнивания. Размер команды, включая word:
```text
opcode 1 224 байта
@@ -896,8 +918,11 @@ Shade cache в GOG `Terrain.dll` имеет vtable RVA `0x643D0`. Его таб
находится по `+320`; банки начинаются с `+332`, их шаг — 212 байт. Lookup
RVA `0x10910` возвращает временное представление по `+24`, а не саму запись.
Построение в RVA `0x10280` и `0x12e20` использует созданную загрузчиком геометрию.
Поэтому повторить terrain shader только разбором WEAR нельзя. Точная композиция
слоёв и микротекстур остаётся открытым вопросом.
В рабочем Vulkan-пути `TerrainMaterialLayers` разрешает для каждого terrain slot
базовую, detail, overlay и overlay-detail фазы из WEAR/MAT0; их UV, alpha и
lightmap state передаются в `VulkanStaticMaterial`, а shader последовательно
сэмплирует эти четыре стадии. Animation clock обновляет выбранные фазы и
коэффициенты без пересборки terrain mesh, сохраняя native type-14 overlay mask.
## Реализация Vulkan
@@ -905,12 +930,23 @@ RVA `0x10910` возвращает временное представление
получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через
push constant; он не требует нового pipeline.
Статический путь `fparkan-game` загружает ландшафт и MSH-компоненты выбранных
объектов. Локальные material slots разрешаются через WEAR и MAT0 перед
объединением геометрии. Индексы GPU имеют тип `u32`: вся карта может содержать
больше 65 535 вершин, даже если каждый исходный mesh использует `u16`.
Текущий путь берёт базовую текстуру Land2; свет, составные слои и атмосфера
пока не участвуют в этом статическом изображении.
Рабочий путь `fparkan-game` загружает terrain и все выбранные mission roots
(по умолчанию весь список объектов), разрешая локальные material slots через
WEAR и MAT0 до объединения геометрии. Индексы GPU имеют тип `u32`: вся карта
может содержать больше 65 535 вершин, даже если каждый исходный mesh использует
`u16`. Terrain передаёт в shader базовый, detail и overlay stages, lightmap и
animation phase; object batches сохраняют их legacy blend/depth/alpha state.
После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом
кадре собирает environment frame. Mission-local `sky.ske`/`sky.wea` выбирают
атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
lightning visual; weather particles, flares, sun/moon, point lights,
projected shadows и audio events обновляются вместе с камерой и временем.
`--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл
бесконечным, а `--preview-roots` служит только диагностическим ограничителем.
`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight
управления.
Для сохранения кадра surface должен поддерживать `TRANSFER_SRC`. Renderer
копирует последний отправленный swapchain image в host-visible buffer и
+169 -34
View File
@@ -143,7 +143,7 @@ references каждого record, жёстко ограничивает counts/a
файла, но не таблица семантик: названия opcode/words появятся только после
handler contracts и runtime traces.
Связь первого header word с dispatch теперь доказана статически: `ai.dll`
Связь первого header word с dispatch доказана статически: `ai.dll`
создаёт 73 handler pointers в известном порядке и копирует table без
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
@@ -201,16 +201,6 @@ Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evalu
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
или связь чисел tag с языковыми операторами.
Выгрузка воспроизводится без изменения PE:
```powershell
& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' `
C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' `
-processor x86:LE:32:default `
-scriptPath C:\Develop\fparkan\tools\ghidra `
-postScript ExportAiExpressionDispatcher.java -deleteProject
```
### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
@@ -292,9 +282,7 @@ Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI фор
а mode передаётся последним; decompiler не восстанавливает предметные имена
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
`0x42478`. Reproducible extractor находится в
`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
в локальном Ghidra project и не изменяет оригинальную DLL.
`0x42478`.
Именно update methods этих private objects, а не пять exports, остаются
следующим объектом динамической трассировки. Поэтому reference movement в
@@ -330,8 +318,7 @@ interface сразу получает пять virtual calls, связывающ
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
связывается с `+0x170`. Это достаточное основание хранить будущий Control
component как ordered raw-string/resource provenance, но не для присвоения
этим строкам смысловых имён до трассировки private update methods. Extractor:
`tools/ghidra/ExportAniMeshControlCaller.java`.
этим строкам смысловых имён до трассировки private update methods.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
@@ -393,22 +380,36 @@ Collision manager не должен хранить прямую незащищё
### CTLD и physical resources
Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources.
В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять
внутренних счётчиков образуют множество вариантов: наиболее частый размер
392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до
1868 байт и более сложные комбинации.
В заголовке CTLD идут пять `u32`; обозначим первые три counts как `S`, `M` и
`T`. Native layout задаёт начало control-row table формулой
`128 + S * (156 + 16 * M) + 4 * S * S`; за ним следуют `T` records по 36 байт.
В record известны node ID (`i32`, `+0`), два endpoint frame (`f32`, `+4`, `+8`),
начальный blend (`f32`, `+0x0c`) и raw flags (`u32`, `+0x20`). Остальные поля
нельзя выводить из этой позовой привязки.
CTLD является составным count-driven форматом, а не фиксированной struct.
Parser должен:
Пример `fr_l_plant.ctl` имеет counts `[14, 0, 5, 11, 13]`: формула даёт
`row_start = 3096 (0xC18)`, а пять control rows заканчиваются на `+0xCCC`.
GOG `Control.dll` function RVA `0x9950` читает blend из `+0x0c` и flags из
`+0x20`. Его branch RVA `0x99C4..0x9A66` применяет flags: при `flags & 0x1`
blend один раз переносится через границу — из значения выше `1` вычитается
`1`, к значению ниже `0` прибавляется `1`; без этого флага blend ограничивается
`[0, 1]`. Затем `flags & 0x2` инвертирует значение (`1 - blend`). Константы
`Control.dll+0x3B188 = 1.0` и `+0x3B18C = 0.0` подтверждают границы этих
сравнений.
- прочитать prefix и все счётчики с проверкой переполнения;
- вычислить границы секций по их counts;
- сохранять неизвестные records в typed raw containers;
- требовать точного завершения payload;
- не использовать размер одного популярного варианта как универсальный layout.
Полная предметная семантика всех секций ещё не доказана, но существующие файлы
можно безопасно читать, индексировать и сохранять.
Control в call site RVA `0x99B5` передаёт в AniMesh через slot `+0x30` пару A
`[-1, -1]` и пару B из endpoint полей строки. В call site `0x9A7B` slot `+0x28`
сохраняет нормализованный blend по `+0x114` и weight `1` по `+0x118`. AniMesh
update RVA `0x8BF2..0x8C76` вычисляет
`frame = (1 - blend) * endpoint_a + blend * endpoint_b`; update RVA `0x12560`
выбирает B-state из-за weight `1`. В static preview строки с `flags & 0x4 != 0`
пропускаются до проверки float полей, поскольку endpoint поля в них могут быть
sentinel `-1`. Повторные node ID сохраняют исходный порядок, поэтому последняя
включённая строка задаёт кадр узла. Привязки сохраняются отдельно для каждого
экземпляра компонента, даже когда `PreparedVisual` разделяется через cache.
Это даёт начальную позу preview, но не запускает игровой Control, AI или полный
runtime animation path. Остальные
секции CTLD shape records и contact solver не входят в эту интерпретацию.
### Terrain queries и movement handoff
@@ -452,10 +453,8 @@ exports; RVA всех пяти exports изменились. Форматы и c
сохранились, но точное physical/collision behavior нельзя считать побайтно тем
же.
CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено;
большинство общих CTLD изменено вместе с переработанными моделями. Это
подтверждает count-driven parser, но не закрывает предметную семантику shape
records и contact solver.
Покадровая привязка выше описывает начальную позу компонента. Она не задаёт
семантику CTLD shape records и не восстанавливает contact solver.
Differential test обеих частей должен воспроизводить движение без препятствий,
slope following, pair collision, timing collision event и удаление объекта в
@@ -569,6 +568,142 @@ Ngi32 создаёт низкоуровневый DirectSound backend. `services
`ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы:
воспроизводят 2D/3D sources, меняют volume и связывают listener с camera.
### Погода, осадки и звуковые события
Погода для игрока — это одновременно движущиеся точки на экране и звуковой
фон. Расписание `sky.ske` говорит, в какой момент действует дождь, снег или
молния, а `sky.wea` назначает имена материалов. Система окружения каждый
кадр превращает это состояние в два списка: мировые частицы для renderer-а и
звуковые переходы для audio backend-а.
```text
sky.ske + sky.wea
-> состояние погоды
-> EnvironmentFrame
-> Particle / Lightning (renderer)
-> StartLoop, SetLoopVolume, StopLoop, OneShot (sound)
```
Такое разделение нужно для понятной границы ответственности. Система погоды
решает, **что** произошло и где находится источник. Renderer решает, как
нарисовать прозрачный квадрат или молнию. Audio backend разрешает архив и
имя, создаёт источник звука и сравнивает его с текущим listener.
У осадков есть объём перед наблюдателем. Эталонные границы имеют глубину
`2..50`, половины углов `0.65` и `0.4875` радиана. Для текущей камеры:
```text
half_y = tan(vertical_fov / 2) * 50
half_x = half_y * aspect_ratio
```
Размер осадков получает тот же camera query, что и native `Terrain`: ширина
viewport делится на горизонтальный FOV в радианах.
```text
horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
precipitation_scalar = viewport_width / horizontal_fov
rain_size = precipitation_scalar * 0.0065
snow_size = precipitation_scalar * 0.0195
```
Число точек получает масштабирование по отношению текущего объёма к
эталонному и округляется к ближайшему чётному целому:
```text
N = round_even(clamp(current_volume / reference_volume, 0, 1)
* density * intensity * 1000)
```
В рабочем эмиттере `density` равна единице. Точка сначала появляется в
локальных координатах этого объёма, затем получает мировую позицию. Дождь
движется с мировым вектором `[0.5, 0, -60]`, снег — `[0.5, 0, -4]`.
Поворот камеры меняет видимую область и проекцию, но не вращает эти векторы.
Когда точка пересекает грань, она переводится в локальные координаты,
циклически переносится на противоположную грань и возвращается в мир.
Поэтому источник звука и положение частицы должны храниться в мировых
координатах. У дождя хвостом экранной полосы становится предыдущая мировая
позиция; у снега остаётся квадрат в текущей позиции. Scalar вычисляется из
projection и реального viewport каждого кадра, поэтому отдельная настройка
размера не нужна.
Дождевой loop следует жизненному циклу состояния:
| Событие | Действие audio backend |
| --- | --- |
| `StartLoop` | открыть объявленный sample и начать пространственный loop |
| `SetLoopVolume` | сохранить loop и применить новую интенсивность как громкость |
| `StopLoop` | остановить текущий loop |
| `OneShot` | создать отдельный источник и воспроизвести sample молнии один раз |
Интенсивность между ключами `sky.ske` интерполируется, поэтому `SetLoopVolume`
может приходить на каждом кадре. Имя из расписания сохраняется. Если указано
только `atm_rain1.wav`, архив остаётся пустым и audio owner использует
библиотеку миссии; запись `archive/name` задаёт архив явно. Ресурс загружается
лениво и кэшируется после проверки, чтобы не читать все возможные погодные
звуки при запуске миссии.
Молния использует отдельный таймер. Для интенсивности `I` и случайного `U` из
15-битного диапазона задержка имеет вид
```text
delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
```
После срока выбираются мировые X и Y из `LightningBounds`, а Z копируется из
границ. Затем объект ждёт 6000 миллисекунд перед новой попыткой. Визуальный
контракт передаёт renderer-у material и numeric body opcode 3; native
descriptor `[40, 40, 600]` начинается на `sampled_z + 300`. При нулевом
локальном смещении его концы находятся на `sampled_z` и `sampled_z + 600`.
Opcode 1 в это же время отдельно обновляет point light; он не задаёт размеры
или UV quad. Звуковой `OneShot` использует ту же позицию, а его opcode 2
параметры `min_distance=100`, `max_distance=1500`, `frequency_ratio=1`
проходят в spatial source. Renderer и audio backend применяют numeric FX
поля и lifetime из заголовка эффекта, а CPU не подменяет их собственной
шириной или fade-кривой.
Подробные поля `EnvironmentFrame`, правила wrap и границы CPU-модуля собраны
в [справочнике эффектов окружения](../reference/environment-effects.md).
### Ambient variations и переход день/ночь
`ambient_music_loop` запускает `THEME` сразу после открытия миссии. Вариации
не выбираются последовательным счётчиком: audio owner получает `dt_seconds`
как приращение времени кадра и после строгого условия `elapsed > delay`
выбирает один sample.
Первый положительный tick поэтому запускает первую вариацию, а задержка между
следующими попытками равна `10 + rand() % 10` секунд.
В `ambient_music_variation` поддерживаются три независимых пула:
| Пул | Ключи | Когда выбирается |
| --- | --- | --- |
| default | `DEFAULT_VARIATION1..n` | когда отсутствуют оба пула `DAY` и `NIGHT` |
| day | `DAY_VARIATION1..n` | длина базового RGB активного небесного объекта больше `1.1` |
| night | `NIGHT_VARIATION1..n` | длина базового RGB активного небесного объекта не больше `1.1` |
Базовый RGB передаётся до camera-dependent glare и берётся у первого активного
небесного объекта. Если существует хотя бы один day/night пул, выбранный
пустой пул остаётся пустым: он не заменяется default или противоположным пулом.
`LIBRARY` у `ambient_music_variation` может отличаться от библиотеки theme;
если поле отсутствует, используется библиотека loop.
Индекс выбирается двумя 16-битными состояниями Iron3D:
```text
a = (a << 1) xor b
b = (b >> 1) xor a
index = b % pool_length
```
При длине пула больше одного предыдущий индекс отбрасывается одной или более
повторными выборками. Последний индекс сохраняется при переходе между day и
night; для пустого пула sample не создаётся, но следующий таймер продолжает
работать. При пустом пуле native сбрасывает индекс в `-1`. Пауза окна сохраняет
логическое состояние таймера и индекса, поэтому
возобновление не перескакивает на случайную вариацию.
Публичные функции Ngi32:
```text
+3 -2
View File
@@ -28,8 +28,9 @@ FParkan развивается небольшими законченными и
Оригинальные ресурсы остаются в установленной игре. Локальные тесты с ними
помечены `#[ignore]`, чтобы обычная проверка работала без коммерческих файлов.
Сообщение об ошибке должно назвать ресурс и причину: например, какой материал
сослался на отсутствующую текстуру. Отдельный отчёт для каждого запуска не нужен.
Сообщение об ошибке должно сразу назвать ресурс и причину: например, какой
материал сослался на отсутствующую текстуру. Поэтому диагностика остаётся
частью самого запуска и не зависит от отдельного отчёта.
## Числа и порядок вычислений
+18 -77
View File
@@ -241,45 +241,20 @@ near/far mapping, handedness или initial camera selection. Важно, что
`ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call
stubs и не дают usable ABI.
Live elevated read-only probe впервые подтвердил relocation-aware runtime связь:
`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал
non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно
relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает
live camera object с outer vtable, но одновременно исправляет прежнее слишком
сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix,
а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/
`0x4D9C0` и exported global ещё не доказаны как один layout без interface
adjustment; их offsets остаются unassigned до recovery selector relationship.
Глобальная camera boundary имеет более точное статическое описание.
`stdGetCurrentCamera2` — короткий getter Terrain по RVA `0x4FD80`, который
читает указатель из global RVA `0x7355C`; initializer по RVA `0x4D4D0`
запрашивает selector `8` у своего `this` и сохраняет полученный interface
pointer. Запрос selector `18` у landscape object проходит через RVA `0x106D0`
и `0x107E0`, возвращает subobject по `base + 0x138`.
Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это
шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA
`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain
по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет
полученный interface pointer. Это доказывает адрес хранения и путь заполнения,
но не разрешает трактовать pointer как конкретный layout камеры либо читать его
как runtime evidence без доступа к процессу на том же уровне привилегий.
Elevated live sampling теперь доказывает, что direct static xref не исчерпывает
runtime writers: за 25 секунд autoplay global переключился между тремя heap
pointer, все с relocated outer vtables `0x025765B4`/`0x02576558`. У двух объектов
paired blocks `+0x2C/+0x3C/+0x4C` и `+0x6C/+0x7C/+0x8C` синхронно несли
world-like translation, например `(491.562, 761.551, 7.361)`; третий давал
normalized-looking `(0.098, 0.018, 0.856)` и не совпадал с paired block.
Наблюдение согласуется с автоматическими camera switches, но не маркирует mode;
оно запрещает называть `0x4D4D0` единственным runtime writer и требует recovery
indirect/unanalyzed write path.
Outer vtable `off_100665B4` теперь даёт exact transform adjustment для
world-like sample. Slot `+0x54` вызывает у subobject `outer + 4` slot `+0x20`
с selector `0`, затем копирует returned `+0x0C/+0x1C/+0x2C`. В live объекте
selector field `outer + 0x10` был `0xFFFFFFFF`; реализация selector `0` при
этом возвращает `outer + 0x20`. Значит observed triple
`outer + 0x2C/+0x3C/+0x4C` — доказанная translation часть active affine
transform, а не корреляция. Selector `2` возвращает paired block `outer + 0x60`;
outer slot `+0x70` применяет `atan2` к его axis values, что доказывает
orientation-angle path. Названия полей, angle order и handedness пока не
установлены, но raw affine transform и translation можно сохранять как
backend-neutral camera pose без догадок.
Внешняя camera vtable по RVA `0x665B4` имеет slot `+0x54`, который вызывает у
subobject `outer + 0x4` slot `+0x20` с selector `0` и копирует возвращённые
компоненты `+0x0C`, `+0x1C` и `+0x2C`. При значении selector field
`outer + 0x10 = -1` selector `0` возвращает `outer + 0x20`; selector `2`
возвращает `outer + 0x60`, а slot `+0x70` выводит углы через `atan2`.
Это фиксирует пути чтения положения и ориентации, но не назначает имена полям,
порядок углов или handedness.
### Vtable и interface negotiation
@@ -288,6 +263,11 @@ backend-neutral camera pose без догадок.
world traversal; camera и viewport получаются через selector-based interface
calls; shared objects используют ранний slot как AddRef-подобную операцию.
Запрос selector `18` у landscape object проходит через RVA `0x106D0` и
`0x107E0`, возвращает subobject по `base + 0x138`, а его vtable slot `+0x18`
ведёт через RVA `0x14230` к обработчику `0x127D0`. Это geometry-interface
boundary, отдельная от хранения активной camera.
Правила реконструкции:
1. Зафиксировать byte offset slot и число аргументов.
@@ -299,45 +279,6 @@ calls; shared objects используют ранний slot как AddRef-по
Нельзя добавлять virtual destructor в начало reconstructed interface: это
сдвинет все slots.
### ABI-матрица Частей 1 и 2
Во всех пятнадцати DLL совпадают export names, ordinals и import sets. Общее
число exports остаётся 313. Обе полные части содержат 1 134 imported function
slots; значение 1 126 относится к демоверсии и хранится отдельно.
Побайтно идентичны девять DLL:
```text
ai.dll
Behavior.dll
Joystick.dll
MisLoad.dll
Net.dll
Ngi32.dll
Terrain.dll
Wizard.dll
World3D.dll
```
Пересобраны `AniMesh.dll`, `ArealMap.dll`, `Control.dll`, `Effect.dll`,
`iron3d.dll`, `services.dll`.
Изменение export RVA:
```text
AniMesh 2 / 2
Control 5 / 5
iron3d 8 / 8
services 6 / 6
ArealMap 0 / 9
Effect 0 / 2
```
Нулевое изменение export RVA не доказывает идентичность тела функции:
`ArealMap.dll` и `Effect.dll` имеют изменённый `.text` при прежних адресах
exports. Compatibility headers фиксируют внешний ABI один раз, но внутренняя
таблица адресов, тестов и semantic deltas выбирается по build fingerprint.
## Файловая поверхность
### Каталог как внешний API
+2
View File
@@ -73,6 +73,8 @@ nav:
- WEAR и MAT0: reference/materials.md
- Texm: reference/texm.md
- Render frame: reference/render-frame.md
- Атмосфера и небо: reference/atmosphere.md
- Эффекты окружения: reference/environment-effects.md
- Оригинальные модули: reference/original-binaries.md
- Приложения:
- Глоссарий: appendices/glossary.md