Files
fparkan/apps/fparkan-game/src/main.rs
T
Valentin Popov 15aadf7f47
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
perf(render): prewarm pipeline variants with a Vulkan cache
2026-11-01 01:31:26 +04:00

17054 lines
651 KiB
Rust

#![forbid(unsafe_code)]
//! `FParkan` render-planning composition root.
mod camera_capture;
mod terrain_lod;
#[cfg(test)]
use camera_capture::parse_legacy_camera_capture;
use camera_capture::{
load_legacy_camera_capture, selected_atmosphere_seconds, LegacyCameraCapture,
};
use fparkan_animation::{AnimationTime, NodePoseBuffer};
use fparkan_assets::{
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root,
load_material_name_texture_rgba8_and_phase_with_document_from_root,
load_resource_entry_bytes_from_root,
load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root,
page_uv_transform, sample_control_node_times, sample_material_normalized_phase,
sample_material_phase, ControlInstanceState, ControlSamplingError, Mat0Document, MaterialPhase,
MaterialPhaseSample, PreparedComponentControl, PreparedControlEffectRow,
PreparedControlEffectSection, PreparedControlGateProvider, PreparedControlNodeBinding,
PreparedControlPoint, PreparedControlPointList, PreparedMaterialPhase, PreparedModel,
PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, StandaloneWearMaterialLoader,
};
use fparkan_fx::ambient_op3::{
evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command,
};
use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState};
use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere};
use fparkan_fx::environment::{
decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera,
EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, PrecipitationKind, ScreenBillboard,
};
use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh};
use fparkan_fx::op7::{
evaluate_op7_emitter, Op7EmitterState, Op7RenderContext, Op7VisibilityProfile,
};
use fparkan_fx::shadow::{
ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache,
ShadowScene, ShadowSphere, ShadowTriangle, SHADOW_PRIMARY_DIRECTIONAL_FLAG,
};
use fparkan_fx::sky::{
sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame,
SkySystem, SunVisibility, SKY_FLARE_ROWS,
};
use fparkan_fx::smoke::{evaluate_op8_emitter, Op8DetailThresholdRange, SmokeEmitterState};
use fparkan_fx::{advance_native_fx_random_state, decode_fxid, FxCommand, FxDocument, FxOpcode};
#[cfg(test)]
use fparkan_material::{MaterialAnimationBlock, MaterialKey};
use fparkan_msh::{
node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times,
selected_slot, Group, Lod, ModelAsset, NodeId, SlotId,
};
use fparkan_path::ResourceName;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO};
use fparkan_render::{
legacy_msh_portal_alpha, LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection,
LegacyDepthBias, LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState,
RawCameraTransform,
};
use fparkan_render_vulkan::{
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
node38_pose_relative_to_root_from_hierarchy, project_land_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,
VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanReadbackArtifact,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex,
};
use fparkan_runtime::{
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
loaded_mission_object_drafts, loaded_terrain, query_building_segment_world,
BasementPlacementMode, BuildingMeshNode, EngineServices, MissionAssets, MissionMode,
MissionObjectDraft, MissionRequest,
};
#[cfg(test)]
use fparkan_terrain::BasementFoundationFragment;
use fparkan_terrain::{
build_basement_foundations, BasementFootprint, BasementFoundation, BasementPlacement,
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
};
use fparkan_vfs::DirectoryVfs;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize as WinitPhysicalSize;
use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{CursorGrabMode, Window, WindowId};
mod audio;
fn main() {
let raw_args = std::env::args().skip(1).collect::<Vec<_>>();
let code = match run(&raw_args) {
Ok(output) => {
println!("{output}");
0
}
Err(err) => {
eprintln!("{err}");
2
}
};
std::process::exit(code);
}
fn run(args: &[String]) -> Result<String, String> {
let args = Args::parse(args)?;
let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)));
let mut engine = create(services).map_err(|err| err.to_string())?;
let mission_assets_started = Instant::now();
let loaded = load_requested_mission(&mut engine, &args)?;
let mission_assets_duration = mission_assets_started.elapsed();
let mission_assets = loaded_mission_assets(&engine)
.ok_or_else(|| "mission assets are unavailable after loading".to_string())?;
let terrain = loaded_terrain(&engine)
.ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
let roots = loaded_mission_object_drafts(&engine)
.map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
.filter(|roots| !roots.is_empty())
.ok_or_else(|| {
"selected mission object drafts are unavailable after loading".to_string()
})?;
let camera = args
.legacy_camera_capture
.as_deref()
.map(load_legacy_camera_capture)
.transpose()?;
let atmosphere_seconds = selected_atmosphere_seconds(args.atmosphere_seconds, camera.as_ref());
let captured_sky_uv_ms = camera
.as_ref()
.and_then(|c| c.sky_uv_clock.as_ref())
.map(|c| c.elapsed_milliseconds);
let hold_schedule_phase = camera.is_some() && atmosphere_seconds.is_some();
let terrain_materials_duration_started = Instant::now();
let preview = static_preview_mesh_and_materials(
mission_assets,
terrain,
roots,
camera,
args.static_animation_frame,
args.static_material_phase,
&args.root,
&loaded.land_msh_path,
)?;
let terrain_materials_duration = terrain_materials_duration_started.elapsed();
// Atmosphere files are mission-local assets. Resolving them from the
// entire install tree would silently attach the first unrelated mission's
// sky to the selected map (there are many `sky.ske` files in the campaign
// tree).
let sky_started = Instant::now();
let mut environment = load_environment(
&args.root,
&args.mission,
atmosphere_seconds,
hold_schedule_phase,
)?;
if let Some(environment) = &mut environment {
environment.fixed_sky_uv_ms = captured_sky_uv_ms;
}
let sky_duration = sky_started.elapsed();
let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission)
{
Ok(audio) => Some(audio),
Err(audio::AudioError::DeviceUnavailable { message }) => {
eprintln!("audio output unavailable; continuing without sound: {message}");
None
}
Err(error) => return Err(format!("load mission audio: {error}")),
};
run_static_vulkan_mode(
preview,
environment,
audio,
args.frames,
&args.mission,
loaded.object_count,
args.readback_out.as_deref(),
args.readback_sequence_out.as_deref(),
args.validation,
args.control_clock_ms,
StartupTimings {
mission_assets: mission_assets_duration,
terrain_materials: terrain_materials_duration,
sky: sky_duration,
gpu_initialization: Duration::ZERO,
},
)
}
fn load_requested_mission(
engine: &mut fparkan_runtime::Engine,
args: &Args,
) -> Result<fparkan_runtime::LoadedMission, String> {
let request = MissionRequest {
key: args.mission.clone(),
mode: MissionMode::SingleMission,
};
if args.preview_roots.get() == 1 {
load_mission_static_preview(engine, request)
} else {
load_mission_static_preview_roots(engine, request, args.preview_roots)
}
.map_err(|err| err.to_string())
}
/// Camera state used by the interactive world-space preview.
#[derive(Clone, Copy, Debug, PartialEq)]
struct FreeFlightCamera {
position: [f32; 3],
yaw: f32,
pitch: f32,
horizontal_fov: f32,
near_plane: f32,
far_plane: f32,
move_speed: f32,
ground_clearance: f32,
}
impl FreeFlightCamera {
fn from_mesh(mesh: &VulkanStaticMesh) -> Result<Self, String> {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for vertex in &mesh.vertices {
for axis in 0..3 {
let value = vertex.position[axis];
if !value.is_finite() {
return Err("world preview contains a non-finite vertex".to_string());
}
min[axis] = min[axis].min(value);
max[axis] = max[axis].max(value);
}
}
if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
return Err("world preview has no finite bounds".to_string());
}
Self::from_bounds(min, max)
}
fn from_bounds(min: [f32; 3], max: [f32; 3]) -> Result<Self, String> {
if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
return Err("free-flight bounds must be finite".to_string());
}
let center = [
(min[0] + max[0]) * 0.5,
(min[1] + max[1]) * 0.5,
(min[2] + max[2]) * 0.5,
];
let radius = (0..3)
.map(|axis| (max[axis] - min[axis]).abs())
.fold(1.0_f32, f32::max);
if !radius.is_finite() || radius <= f32::EPSILON {
return Err("free-flight bounds are degenerate".to_string());
}
let position = [
center[0],
center[1] - radius * 2.5,
center[2] + radius * 1.25,
];
let direction = sub3(center, position);
let distance = length3(direction)
.ok_or_else(|| "free-flight initial camera has no direction to the map".to_string())?;
let yaw = direction[1].atan2(direction[0]);
let pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin();
Ok(Self {
position,
yaw,
pitch,
horizontal_fov: 1.3,
near_plane: 0.5,
far_plane: 700.0,
// Keep the default traversal speed comfortable on a full mission
// map; Shift/Ctrl still provide the coarse speed controls.
move_speed: (radius * 0.03).clamp(4.0, 40.0),
ground_clearance: 0.0,
})
}
fn from_mesh_and_terrain(
mesh: &VulkanStaticMesh,
terrain: &TerrainWorld,
) -> Result<Self, String> {
let mut camera = Self::from_mesh(mesh)?;
let positions = terrain
.source_positions()
.ok_or_else(|| "free-flight terrain has no source positions".to_string())?;
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for position in positions {
if !position.iter().all(|value| value.is_finite()) {
return Err("free-flight terrain contains a non-finite position".to_string());
}
for axis in 0..3 {
min[axis] = min[axis].min(position[axis]);
max[axis] = max[axis].max(position[axis]);
}
}
if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
return Err("free-flight terrain has no finite bounds".to_string());
}
let extent = (max[0] - min[0]).max(max[1] - min[1]);
if !extent.is_finite() || extent <= f32::EPSILON {
return Err("free-flight terrain has degenerate XY bounds".to_string());
}
let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
let offset = extent * 0.18;
let requested_spawn = [
(center[0] - offset).clamp(min[0], max[0]),
(center[1] - offset).clamp(min[1], max[1]),
];
let sample_offsets = [
[0.0, 0.0],
[0.18, 0.18],
[-0.18, 0.18],
[0.18, -0.18],
[-0.18, -0.18],
[0.0, 0.18],
[0.18, 0.0],
[-0.18, 0.0],
[0.0, -0.18],
];
let height_world = |xy: [f32; 2]| {
terrain
.height_at(xy)
.ok()
.flatten()
.filter(|height| height.is_finite())
};
let (spawn_xy, spawn_ground) = sample_offsets
.into_iter()
.map(|sample| {
[
(requested_spawn[0] + sample[0] * extent).clamp(min[0], max[0]),
(requested_spawn[1] + sample[1] * extent).clamp(min[1], max[1]),
]
})
.find_map(|xy| height_world(xy).map(|height| (xy, height)))
.ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?;
let target_ground = height_world(center).unwrap_or(spawn_ground);
let clearance = (extent * 0.005).clamp(2.0, 8.0);
camera.ground_clearance = clearance;
camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance];
let target = [center[0], center[1], target_ground + 1.5];
let direction = sub3(target, camera.position);
let distance = length3(direction)
.ok_or_else(|| "free-flight terrain spawn has no view direction".to_string())?;
camera.yaw = direction[1].atan2(direction[0]);
camera.pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin();
Ok(camera)
}
fn keep_above_terrain(&mut self, terrain: &TerrainWorld) -> Result<(), String> {
if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 {
return Err("free-flight ground clearance must be finite and nonnegative".to_string());
}
if let Some([min, max]) = terrain.surface_bounds() {
for axis in 0..2 {
self.position[axis] = self.position[axis].clamp(min[axis], max[axis]);
}
}
let Some(ground_height) = terrain
.height_at([self.position[0], self.position[1]])
.map_err(|error| format!("free-flight terrain height query: {error}"))?
else {
return Ok(());
};
if !ground_height.is_finite() {
return Err("free-flight terrain height is not finite".to_string());
}
let minimum_z = ground_height + self.ground_clearance;
if !minimum_z.is_finite() {
return Err("free-flight terrain floor is not finite".to_string());
}
self.position[2] = self.position[2].max(minimum_z);
Ok(())
}
fn forward(self) -> [f32; 3] {
let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
[cos_pitch * cos_yaw, cos_pitch * sin_yaw, sin_pitch]
}
fn right(self) -> [f32; 3] {
normalize3(cross3(self.forward(), [0.0, 0.0, 1.0])).unwrap_or([1.0, 0.0, 0.0])
}
fn up(self) -> [f32; 3] {
normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0])
}
fn vertical_fov(self, aspect: f32) -> f32 {
let aspect = if aspect.is_finite() && aspect > f32::EPSILON {
aspect
} else {
16.0 / 9.0
};
2.0 * ((self.horizontal_fov * 0.5).tan() / aspect).atan()
}
fn look_delta(&mut self, dx: f64, dy: f64) {
const SENSITIVITY: f32 = 0.0025;
let dx = dx as f32;
let dy = dy as f32;
if dx.is_finite() && dy.is_finite() {
// Moving the mouse right turns the view to the right.
self.yaw -= dx * SENSITIVITY;
self.pitch = (self.pitch - dy * SENSITIVITY).clamp(-1.5, 1.5);
}
}
fn advance(&mut self, keys: &HashSet<KeyCode>, seconds: f32) {
if !seconds.is_finite() || seconds <= 0.0 {
return;
}
let mut movement = [0.0; 3];
let forward = self.forward();
let right = self.right();
if keys.contains(&KeyCode::KeyW) {
add3_in_place(&mut movement, forward);
}
if keys.contains(&KeyCode::KeyS) {
add3_in_place(&mut movement, scale3(forward, -1.0));
}
if keys.contains(&KeyCode::KeyD) {
add3_in_place(&mut movement, right);
}
if keys.contains(&KeyCode::KeyA) {
add3_in_place(&mut movement, scale3(right, -1.0));
}
if keys.contains(&KeyCode::KeyE) {
add3_in_place(&mut movement, [0.0, 0.0, 1.0]);
}
if keys.contains(&KeyCode::KeyQ) {
add3_in_place(&mut movement, [0.0, 0.0, -1.0]);
}
let Some(direction) = normalize3(movement) else {
return;
};
let mut speed = self.move_speed;
if keys.contains(&KeyCode::ShiftLeft) || keys.contains(&KeyCode::ShiftRight) {
speed *= 4.0;
}
if keys.contains(&KeyCode::ControlLeft) || keys.contains(&KeyCode::ControlRight) {
speed *= 0.25;
}
self.position = add3(self.position, scale3(direction, speed * seconds));
}
fn vulkan_camera(self, aspect: f32) -> VulkanStaticCamera {
VulkanStaticCamera {
clip_from_world: clip_from_world_for_view(
self.position,
self.forward(),
self.right(),
self.up(),
self.vertical_fov(aspect),
self.near_plane,
self.far_plane,
aspect,
),
}
}
fn preview_frame(self, aspect: f32) -> PreviewCameraFrame {
let camera = self.vulkan_camera(aspect);
PreviewCameraFrame {
position: self.position,
forward: self.forward(),
right: self.right(),
up: self.up(),
vertical_fov: self.vertical_fov(aspect),
near_plane: self.near_plane,
far_plane: self.far_plane,
clip_from_world: camera.clip_from_world,
}
}
}
fn preview_aspect(aspect: f32) -> f32 {
if aspect.is_finite() && aspect > f32::EPSILON {
aspect
} else {
16.0 / 9.0
}
}
fn clip_from_world_for_view(
position: [f32; 3],
forward: [f32; 3],
right: [f32; 3],
up: [f32; 3],
vertical_fov: f32,
near_plane: f32,
far_plane: f32,
aspect: f32,
) -> [f32; 16] {
let aspect = preview_aspect(aspect);
// VulkanStaticCamera carries row-major D3D-style data. GLSL interprets
// those bytes as a column-major mat4, so shader multiplication is the
// transpose of this row-vector transform: world * V * P. The camera
// basis is right-handed with +Z-up world coordinates and +Z depth.
let view = [
right[0],
up[0],
forward[0],
0.0,
right[1],
up[1],
forward[1],
0.0,
right[2],
up[2],
forward[2],
0.0,
-dot3(right, position),
-dot3(up, position),
-dot3(forward, position),
1.0,
];
let focal = (vertical_fov * 0.5).tan().recip();
let near_plane = near_plane.max(0.001);
let far_plane = far_plane.max(near_plane + 0.001);
let projection = [
focal / aspect,
0.0,
0.0,
0.0,
0.0,
-focal,
0.0,
0.0,
0.0,
0.0,
far_plane / (far_plane - near_plane),
1.0,
0.0,
0.0,
-(near_plane * far_plane) / (far_plane - near_plane),
0.0,
];
multiply_row_major(view, projection)
}
/// Immutable view data shared by the environment renderers. The optional
/// `FreeFlightCamera` remains only the input controller; captured views keep
/// their original basis and clip transform, including roll.
#[derive(Clone, Copy, Debug, PartialEq)]
struct PreviewCameraFrame {
position: [f32; 3],
forward: [f32; 3],
right: [f32; 3],
up: [f32; 3],
vertical_fov: f32,
near_plane: f32,
far_plane: f32,
clip_from_world: [f32; 16],
}
impl PreviewCameraFrame {
fn from_legacy_d3d7(
transform: RawCameraTransform,
projection: LegacyD3d7Projection,
) -> Option<Self> {
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)?;
let view = transform.try_direct3d7_view_row_major()?;
let width = projection.viewport[2].checked_sub(projection.viewport[0])?;
let height = projection.viewport[3].checked_sub(projection.viewport[1])?;
if width <= 0 || height <= 0 {
return None;
}
let aspect = width as f32 / height as f32;
let vertical_fov = 2.0 * ((projection.field_of_view_radians * 0.5).tan() / aspect).atan();
let frame = Self {
position: transform.translation(),
// The camera axes are the columns of Ngi32's D3D7 view matrix.
// Keeping them directly preserves native roll and avoids
// reconstructing an incomplete yaw/pitch camera.
right: [view[0], view[4], view[8]],
up: [view[1], view[5], view[9]],
forward: [view[2], view[6], view[10]],
vertical_fov,
near_plane: projection.near_plane,
far_plane: projection.far_plane,
clip_from_world: camera.clip_from_world,
};
(frame.position.iter().all(|value| value.is_finite())
&& frame.forward.iter().all(|value| value.is_finite())
&& frame.right.iter().all(|value| value.is_finite())
&& frame.up.iter().all(|value| value.is_finite())
&& frame.vertical_fov.is_finite()
&& frame.vertical_fov > 0.0
&& frame.vertical_fov < std::f32::consts::PI)
.then_some(frame)
}
fn vulkan_camera(self) -> VulkanStaticCamera {
VulkanStaticCamera {
clip_from_world: self.clip_from_world,
}
}
fn with_aspect(self, previous_aspect: f32, aspect: f32) -> Self {
let previous_aspect = preview_aspect(previous_aspect);
let aspect = preview_aspect(aspect);
let horizontal_half_fov_tangent = (self.vertical_fov * 0.5).tan() * previous_aspect;
let vertical_fov = 2.0 * (horizontal_half_fov_tangent / aspect).atan();
let clip_from_world = clip_from_world_for_view(
self.position,
self.forward,
self.right,
self.up,
vertical_fov,
self.near_plane,
self.far_plane,
aspect,
);
Self {
vertical_fov,
clip_from_world,
..self
}
}
fn forward(self) -> [f32; 3] {
self.forward
}
fn right(self) -> [f32; 3] {
self.right
}
fn up(self) -> [f32; 3] {
self.up
}
fn native_sky_heading(self) -> f32 {
// Terrain+0x4F9C0 uses atan2(forward.x, forward.y); CSky+0x481C5 adds pi later.
self.forward[0].atan2(self.forward[1])
}
}
const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4];
const ENVIRONMENT_FLARE_SLOTS: usize = 12;
const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000;
// shortcut: Effect.dll derives this from projected sphere size and a render-context
// quality table. Category 0 supplies a highest-detail mesh until that exact
// world category is available; this does not reproduce native LOD selection.
const AMBIENT_EFFECT_NATIVE_CATEGORY: u8 = 0;
#[derive(Clone, Copy, Debug)]
struct EnvironmentGpuRange {
range_index: usize,
vertex_start: usize,
vertex_capacity: usize,
material_index: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AmbientEffectKind {
Op3Type0,
Op3Type1,
Op3Type2,
GenerBall,
SmokePool,
Op7ParticlePool,
}
#[derive(Clone, Debug)]
struct AmbientEffectGpuSlot {
kind: AmbientEffectKind,
time_mode: u32,
header_flags: u32,
random_instance_key: ControlEffectInstanceKey,
random_seed: u32,
phase_jitter: f32,
random_shift: [f32; 3],
owner_object_index: usize,
owner_component_index: usize,
owner_node_index: usize,
owner_component_is_mounted: bool,
root_transform: LegacyIron3dEulerTransform,
owner_scale: [f32; 3],
mount_pose: Option<VulkanNodePose>,
local_from_effect: [[f32; 4]; 4],
control_source: String,
control_table_index: usize,
control_row_index: usize,
control_point_indices: [u32; 3],
control_point_owners: [i32; 3],
command: FxCommand,
command_index: usize,
duration_seconds: f32,
world_from_effect: [[f32; 4]; 4],
range: EnvironmentGpuRange,
additional_particle_ranges: Vec<EnvironmentGpuRange>,
material_animation_index: usize,
additional_particle_material_animation_indices: Vec<usize>,
smoke_emitter_state: Option<SmokeEmitterState>,
op7_emitter_state: Option<Op7EmitterState>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct AmbientEffectMeshPrimitive {
vertex_start: usize,
vertex_count: usize,
index_start: usize,
index_count: usize,
}
#[derive(Clone, Debug)]
struct ControlEffectSlot {
time_mode: u32,
header_flags: u32,
random_instance_key: ControlEffectInstanceKey,
random_seed: u32,
phase_jitter: f32,
random_shift: [f32; 3],
duration_seconds: f32,
owner_object_index: usize,
owner_component_index: usize,
owner_node_index: usize,
owner_component_is_mounted: bool,
root_transform: LegacyIron3dEulerTransform,
owner_scale: [f32; 3],
mount_pose: Option<VulkanNodePose>,
local_from_effect: [[f32; 4]; 4],
world_from_effect: [[f32; 4]; 4],
command_index: usize,
command: FxCommand,
}
#[derive(Clone, Debug)]
struct ControlSoundSlot {
effect: ControlEffectSlot,
command: Op2SoundCommand,
state: Op2SoundState,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct ControlEffectInstanceKey {
object_index: usize,
component_index: usize,
table_index: usize,
row_index: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct ControlEffectRandomSample {
phase_jitter: f32,
accumulated_shift: [f32; 3],
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SceneSampleId {
Live(u64),
Replay(u64),
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct FrameClockSample {
id: SceneSampleId,
time_ms: u32,
delta_seconds: f32,
}
#[derive(Clone, Debug)]
struct NativeFxRandom16 {
state: u32,
}
impl NativeFxRandom16 {
fn new(seed: u32) -> Self {
Self {
state: if seed == 0 { 0xA341_316C } else { seed },
}
}
fn next_u16(&mut self) -> u16 {
advance_native_fx_random_state(&mut self.state)
}
fn symmetric_sample(&mut self, amplitude: f32) -> f32 {
(f32::from(self.next_u16()) / 65_536.0 - 0.5) * amplitude
}
}
#[derive(Clone, Debug)]
struct ControlEffectRandomState {
header_flags: u32,
phase_jitter_amplitude: f32,
random_shift_amplitude: [f32; 3],
phase_and_z_rng: NativeFxRandom16,
xy_rng: NativeFxRandom16,
sample: ControlEffectRandomSample,
last_update_id: Option<SceneSampleId>,
last_update_clock_ms: Option<u32>,
}
impl ControlEffectRandomState {
fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self {
Self {
header_flags,
phase_jitter_amplitude: phase_jitter,
random_shift_amplitude: random_shift,
phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3),
xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595),
sample: ControlEffectRandomSample::default(),
last_update_id: None,
last_update_clock_ms: None,
}
}
fn advance(
&mut self,
sample_id: SceneSampleId,
control_clock_ms: u32,
initial_phase: f32,
) -> ControlEffectRandomSample {
// Native updates consume process-global RNG streams. The preview uses
// a deterministic per-Control-row stream; sibling slots share one
// sample and cadence, but native cross-row RNG ordering remains open.
if self.last_update_id == Some(sample_id) {
return self.sample;
}
if self.header_flags & 1 != 0 && initial_phase != -1.0 {
self.sample.phase_jitter = self
.phase_and_z_rng
.symmetric_sample(self.phase_jitter_amplitude);
} else {
self.sample.phase_jitter = 0.0;
}
if self.header_flags & 8 != 0 {
let shift_z = self
.phase_and_z_rng
.symmetric_sample(self.random_shift_amplitude[2]);
let shift_y = self.xy_rng.symmetric_sample(self.random_shift_amplitude[1]);
let shift_x = self.xy_rng.symmetric_sample(self.random_shift_amplitude[0]);
self.sample.accumulated_shift[0] += shift_x;
self.sample.accumulated_shift[1] += shift_y;
self.sample.accumulated_shift[2] += shift_z;
}
self.last_update_id = Some(sample_id);
self.last_update_clock_ms = Some(control_clock_ms);
self.sample
}
}
#[derive(Clone, Debug)]
struct PreparedEffectMaterial {
document: Mat0Document,
phase_material_indices: Vec<usize>,
phase_uv_transforms: Vec<[f32; 4]>,
phase_native_flag_40: Vec<bool>,
phase_directional_rgb: Vec<[f32; 3]>,
phase_additive_rgb: Vec<[f32; 3]>,
initial_pipeline_category: u8,
}
#[derive(Clone, Debug)]
struct EnvironmentMaterialAsset {
texture: VulkanStaticTexture,
phase: PreparedMaterialPhase,
document: Mat0Document,
phase_textures: Vec<VulkanStaticTexture>,
phases: Vec<PreparedMaterialPhase>,
}
/// One source MAT0 document retained for frame-time sampling. The renderer
/// receives the selected coefficients through its dynamic material state;
/// keeping this binding beside the prepared scene avoids rebuilding assets or
/// reopening archives on every redraw.
#[derive(Clone, Debug)]
struct MaterialPhaseBinding {
document: Mat0Document,
animation_block_index: usize,
wear_row_start_ms: u32,
random_state: u32,
frozen_phase_index: Option<usize>,
phase_uv_transforms: Vec<[f32; 4]>,
}
/// Texture phases for the material stages that do not select the draw
/// variant. Terrain MAT0 rows are independent: Land1 base, Land2 detail,
/// Land1 overlay, and Land2 overlay-detail can each have their own animation
/// timeline. Keeping these handles beside the binding lets the renderer
/// update the three descriptor images in place without materialising every
/// Cartesian product of phase indices.
#[derive(Clone, Debug)]
struct MaterialStageTextures {
detail: Option<Vec<VulkanStaticTexture>>,
overlay: Option<Vec<VulkanStaticTexture>>,
overlay_detail: Option<Vec<VulkanStaticTexture>>,
applied_material_phases: HashMap<usize, [usize; 3]>,
}
impl MaterialPhaseBinding {
fn sample(
&mut self,
material_index: usize,
clock_ms: u32,
) -> Result<MaterialPhaseSample, String> {
if let Some(phase_index) = self.frozen_phase_index {
let phase_index = phase_index.min(self.document.phases.len().saturating_sub(1));
let phase = self.document.phases.get(phase_index).ok_or_else(|| {
format!(
"sample MAT0 material {material_index} at clock {clock_ms}ms: no material phases"
)
})?;
return Ok(MaterialPhaseSample {
phase_index,
coefficients: phase.coefficients(),
texture_raw: phase.texture_raw,
});
}
// GetPhase's random mode asks the CRT generator for one fresh value
// per query. Animated loop/ping-pong/clamp modes do not consume the
// RNG and receive the sampler's ignored value.
let block = self
.document
.animation_blocks
.get(self.animation_block_index)
.or_else(|| self.document.animation_blocks.first());
let random_value = if block.is_some_and(|block| block.header_raw & 7 == 3) {
self.random_state = self
.random_state
.wrapping_mul(214_013)
.wrapping_add(2_531_011);
(self.random_state >> 16) & 0x7fff
} else {
0
};
sample_material_phase(
&self.document,
self.animation_block_index,
clock_ms,
self.wear_row_start_ms,
random_value,
)
.map_err(|error| {
format!(
"sample MAT0 material {} at clock {clock_ms}ms: {error}",
material_index
)
})
}
fn sample_normalized(
&self,
material_index: usize,
normalized_phase: f32,
) -> Result<MaterialPhaseSample, String> {
sample_material_normalized_phase(
&self.document,
self.animation_block_index,
normalized_phase,
)
.map_err(|error| {
format!(
"sample MAT0 material {material_index} at normalized phase {normalized_phase}: {error}"
)
})
}
fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] {
self.phase_uv_transforms
.get(phase_index)
.copied()
.or_else(|| self.phase_uv_transforms.first().copied())
.unwrap_or([0.0, 0.0, 1.0, 1.0])
}
fn checked_page_uv_transform(&self, phase_index: usize) -> Result<[f32; 4], String> {
self.phase_uv_transforms
.get(phase_index)
.copied()
.ok_or_else(|| {
format!(
"MAT0 Page transform is missing phase {phase_index} of {}",
self.phase_uv_transforms.len()
)
})
}
}
#[derive(Clone, Debug)]
struct MaterialVariantSet {
phase_material_indices: Vec<usize>,
range_indices: Vec<usize>,
pipeline_category: u8,
}
#[derive(Clone, Debug)]
struct MaterialAnimationBinding {
initial_phase_index: usize,
normalized_phase_override: Option<f32>,
ambient_fx_material_alpha: bool,
variants: Vec<MaterialVariantSet>,
base: MaterialPhaseBinding,
detail: Option<MaterialPhaseBinding>,
overlay: Option<MaterialPhaseBinding>,
overlay_detail: Option<MaterialPhaseBinding>,
stage_textures: Option<MaterialStageTextures>,
}
#[derive(Clone, Copy, Debug)]
struct ActiveMaterialState {
material_index: usize,
phase_index: usize,
diffuse_alpha: f32,
additive_rgb: [f32; 3],
}
fn ambient_effect_material_alpha(is_ambient_effect: bool, sampled_opacity: f32) -> f32 {
if is_ambient_effect {
1.0
} else {
sampled_opacity
}
}
fn sample_material_animation_base(
binding: &mut MaterialAnimationBinding,
clock_ms: u32,
) -> Result<MaterialPhaseSample, String> {
match binding.normalized_phase_override {
Some(normalized_phase) => binding
.base
.sample_normalized(binding.initial_phase_index, normalized_phase),
None => binding.base.sample(binding.initial_phase_index, clock_ms),
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AmbientEffectGeometryUpdate {
range_index: usize,
index_count: u32,
command_rgb: [f32; 3],
}
fn selected_material_phase(
variant: &MaterialVariantSet,
phase_index: usize,
pipeline_category_for: impl FnOnce(usize) -> Option<u8>,
) -> Result<(usize, u8), String> {
let material_index = selected_material_variant_index(variant, phase_index)
.ok_or_else(|| "MAT0 material animation has no texture variant".to_string())?;
let pipeline_category = pipeline_category_for(material_index).ok_or_else(|| {
format!("MAT0 material animation selector {material_index} is out of bounds")
})?;
Ok((material_index, pipeline_category))
}
#[derive(Clone, Copy, Debug)]
struct MaterialRangeSelection {
range_index: usize,
material_index: usize,
pipeline_category: u8,
}
#[derive(Clone, Copy, Debug)]
struct MaterialPropertiesUpdate {
material_index: usize,
diffuse_alpha: f32,
overlay_alpha: f32,
base_directional: [f32; 3],
base_additive: [f32; 3],
overlay_directional: Option<[f32; 3]>,
overlay_additive: Option<[f32; 3]>,
specular_rgb: [f32; 3],
specular_power: u8,
overlay_specular_rgb: Option<[f32; 3]>,
overlay_power: Option<u8>,
uv_transforms: [[f32; 4]; 4],
}
#[derive(Clone, Copy, Debug)]
struct MaterialStagePhaseUpdate {
binding_index: usize,
material_index: usize,
phases: [usize; 3],
}
#[derive(Clone, Debug, Default)]
struct MaterialFrameUpdate {
active_materials: HashMap<usize, ActiveMaterialState>,
range_selections: Vec<MaterialRangeSelection>,
properties: Vec<MaterialPropertiesUpdate>,
stage_phases: Vec<MaterialStagePhaseUpdate>,
}
fn sample_material_animations(
bindings: &mut [MaterialAnimationBinding],
materials: &[VulkanStaticMaterial],
clock_ms: u32,
) -> Result<MaterialFrameUpdate, String> {
let mut update = MaterialFrameUpdate::default();
for (binding_index, binding) in bindings.iter_mut().enumerate() {
let base_sample = sample_material_animation_base(binding, clock_ms)?;
let material_alpha = ambient_effect_material_alpha(
binding.ambient_fx_material_alpha,
base_sample.coefficients.opacity,
);
let detail_sample = binding
.detail
.as_mut()
.map(|detail| detail.sample(binding.initial_phase_index, clock_ms))
.transpose()?;
let overlay_sample = binding
.overlay
.as_mut()
.map(|overlay| overlay.sample(binding.initial_phase_index, clock_ms))
.transpose()?;
let overlay_detail_sample = binding
.overlay_detail
.as_mut()
.map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms))
.transpose()?;
let mut updated_materials = HashSet::new();
for variant in &mut binding.variants {
let (material_index, pipeline_category) =
selected_material_phase(variant, base_sample.phase_index, |index| {
materials
.get(index)
.map(|material| material.pipeline_category)
})?;
for &range_index in &variant.range_indices {
update.active_materials.insert(
range_index,
ActiveMaterialState {
material_index,
phase_index: base_sample.phase_index,
diffuse_alpha: material_alpha,
additive_rgb: base_sample.coefficients.additive_rgb,
},
);
update.range_selections.push(MaterialRangeSelection {
range_index,
material_index,
pipeline_category,
});
}
variant.pipeline_category = pipeline_category;
if !updated_materials.insert(material_index) {
continue;
}
let material = materials.get(material_index).ok_or_else(|| {
format!("MAT0 material animation selector {material_index} is out of bounds")
})?;
let base_coefficients = base_sample.coefficients;
let overlay_coefficients = overlay_sample.map(|sample| sample.coefficients);
let (base_directional, base_additive, overlay_directional, overlay_additive) =
if material.lightmap_mode {
// Terrain mode 3 samples the lightmap in the secondary
// stage and uses the authored directional RGB as additive.
([0.0; 3], base_coefficients.directional_rgb, None, None)
} else {
(
base_coefficients.directional_rgb,
base_coefficients.additive_rgb,
overlay_coefficients.map(|coefficients| coefficients.directional_rgb),
overlay_coefficients.map(|coefficients| coefficients.additive_rgb),
)
};
if !material.lightmap_mode {
if let Some(stage_textures) = binding.stage_textures.as_ref() {
let stage_phases = [
detail_sample.map_or(0, |sample| sample.phase_index),
overlay_sample.map_or(0, |sample| sample.phase_index),
overlay_detail_sample.map_or(0, |sample| sample.phase_index),
];
if stage_textures
.applied_material_phases
.get(&material_index)
.copied()
!= Some(stage_phases)
{
update.stage_phases.push(MaterialStagePhaseUpdate {
binding_index,
material_index,
phases: stage_phases,
});
}
}
}
let overlay_uv = overlay_sample
.map(|sample| {
binding
.overlay
.as_ref()
.map_or([0.0, 0.0, 1.0, 1.0], |overlay| {
overlay.page_uv_transform(sample.phase_index)
})
})
.or(material.overlay_uv_transform)
.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let detail_uv = if material.lightmap_mode {
material.detail_uv_transform
} else {
detail_sample
.map(|sample| {
binding
.detail
.as_ref()
.map_or(material.detail_uv_transform, |detail| {
detail.page_uv_transform(sample.phase_index)
})
})
.or(Some(material.detail_uv_transform))
.unwrap_or([0.0, 0.0, 1.0, 1.0])
};
let overlay_detail_uv = if material.lightmap_mode {
material
.overlay_detail_uv_transform
.unwrap_or([0.0, 0.0, 1.0, 1.0])
} else {
overlay_detail_sample
.map(|sample| {
binding.overlay_detail.as_ref().map_or(
material
.overlay_detail_uv_transform
.unwrap_or([0.0, 0.0, 1.0, 1.0]),
|overlay_detail| overlay_detail.page_uv_transform(sample.phase_index),
)
})
.or(material.overlay_detail_uv_transform)
.unwrap_or([0.0, 0.0, 1.0, 1.0])
};
let base_uv = binding
.base
.checked_page_uv_transform(base_sample.phase_index)?;
update.properties.push(MaterialPropertiesUpdate {
material_index,
diffuse_alpha: material_alpha.clamp(0.0, 1.0),
overlay_alpha: overlay_coefficients
.map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)),
base_directional,
base_additive,
overlay_directional,
overlay_additive,
specular_rgb: base_coefficients.specular_rgb,
specular_power: base_coefficients.power,
overlay_specular_rgb: overlay_coefficients
.map(|coefficients| coefficients.specular_rgb),
overlay_power: overlay_coefficients.map(|coefficients| coefficients.power),
uv_transforms: [base_uv, detail_uv, overlay_uv, overlay_detail_uv],
});
}
}
Ok(update)
}
fn apply_material_frame_update(
update: &MaterialFrameUpdate,
bindings: &mut [MaterialAnimationBinding],
renderer: &mut VulkanSmokeRenderer,
) -> Result<(), String> {
for selection in &update.range_selections {
renderer
.set_draw_range_material(selection.range_index, selection.material_index)
.map_err(|error| format!("update MAT0 material draw selector: {error}"))?;
renderer
.set_draw_range_material_category(selection.range_index, selection.pipeline_category)
.map_err(|error| format!("update MAT0 material pipeline category: {error}"))?;
}
for properties in &update.properties {
renderer
.set_material_alphas(
properties.material_index,
properties.diffuse_alpha,
properties.overlay_alpha,
)
.map_err(|error| format!("update MAT0 material alpha: {error}"))?;
renderer
.set_material_lighting_with_overlay(
properties.material_index,
properties.base_directional,
properties.base_additive,
properties.overlay_directional,
properties.overlay_additive,
)
.map_err(|error| format!("update MAT0 material lighting: {error}"))?;
renderer
.set_material_specular(
properties.material_index,
properties.specular_rgb,
properties.specular_power,
properties.overlay_specular_rgb,
properties.overlay_power,
)
.map_err(|error| format!("update MAT0 material specular: {error}"))?;
renderer
.set_material_uv_transforms(properties.material_index, properties.uv_transforms)
.map_err(|error| format!("update MAT0 material Page transform: {error}"))?;
}
for stage_update in &update.stage_phases {
let stage_textures = bindings
.get_mut(stage_update.binding_index)
.and_then(|binding| binding.stage_textures.as_mut())
.ok_or_else(|| "MAT0 stage texture binding disappeared".to_string())?;
if stage_textures
.applied_material_phases
.get(&stage_update.material_index)
.copied()
== Some(stage_update.phases)
{
continue;
}
let textures = [
stage_textures
.detail
.as_ref()
.and_then(|textures| textures.get(stage_update.phases[0])),
stage_textures
.overlay
.as_ref()
.and_then(|textures| textures.get(stage_update.phases[1])),
stage_textures
.overlay_detail
.as_ref()
.and_then(|textures| textures.get(stage_update.phases[2])),
];
for (stage, texture) in textures.into_iter().enumerate() {
if let Some(texture) = texture {
renderer
.update_material_texture(stage_update.material_index, stage + 1, texture)
.map_err(|error| match stage {
0 => format!("update MAT0 detail texture: {error}"),
1 => format!("update MAT0 overlay texture: {error}"),
_ => format!("update MAT0 overlay detail texture: {error}"),
})?;
}
}
stage_textures
.applied_material_phases
.insert(stage_update.material_index, stage_update.phases);
}
Ok(())
}
fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -> bool {
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
}
fn native_msh_diffuse_alpha_override(
mesh: &VulkanStaticMesh,
range_index: usize,
type16_present: bool,
eye_world: [f32; 3],
horizontal_fov: f32,
) -> Result<Option<f32>, String> {
let range = mesh
.draw_ranges
.get(range_index)
.ok_or_else(|| "portal draw range is out of bounds".to_string())?;
if type16_present || range.batch_flags & 0x0008 == 0 {
return Ok(None);
}
let distance = if range.batch_flags & 0x0050 == 0x0040 {
0.0
} else {
// shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available.
first_indexed_vertex_distance(mesh, range_index, eye_world)?
};
legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov)
.map(Some)
.ok_or_else(|| "portal distance or field of view is invalid".to_string())
}
fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 {
let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else {
return 0;
};
let Some(x_axis) = transform
.try_transform_scaled_point([1.0, 0.0, 0.0], scale)
.map(|point| sub3(point, origin))
else {
return 0;
};
let Some(y_axis) = transform
.try_transform_scaled_point([0.0, 1.0, 0.0], scale)
.map(|point| sub3(point, origin))
else {
return 0;
};
let Some(z_axis) = transform
.try_transform_scaled_point([0.0, 0.0, 1.0], scale)
.map(|point| sub3(point, origin))
else {
return 0;
};
// Node and mount poses in the preview are unit-quaternion rotations, so
// they have determinant +1 and preserve this exact object-transform sign.
let determinant_sign = dot3(x_axis, cross3(y_axis, z_axis));
if !determinant_sign.is_finite() || determinant_sign == 0.0 {
0
} else if determinant_sign < 0.0 {
-1
} else {
1
}
}
fn native_msh_cull_mode(batch_flags: u16, handedness: i8) -> LegacyCullMode {
if batch_flags & 0x2 != 0 || batch_flags & 0x4000 != 0 || handedness == 0 {
LegacyCullMode::Disabled
} else if handedness > 0 {
// The Y-negated projection maps native d > 0 faces to Vulkan's back
// under the renderer's CLOCKWISE front-face convention.
LegacyCullMode::FrontFace
} else {
LegacyCullMode::BackFace
}
}
fn native_msh_first_triangle_visible(
mesh: &VulkanStaticMesh,
range: &VulkanStaticDrawRange,
eye_world: [f32; 3],
handedness: i8,
) -> bool {
if range.batch_flags & 0x2 != 0 || range.batch_flags & 0x4000 == 0 {
return true;
}
if handedness == 0 {
return false;
}
let Some(indices) = usize::try_from(range.first_index)
.ok()
.and_then(|first| mesh.indices.get(first..first.checked_add(3)?))
else {
return false;
};
let Some(v0) = usize::try_from(indices[0])
.ok()
.and_then(|index| mesh.vertices.get(index))
else {
return false;
};
let Some(v1) = usize::try_from(indices[1])
.ok()
.and_then(|index| mesh.vertices.get(index))
else {
return false;
};
let Some(v2) = usize::try_from(indices[2])
.ok()
.and_then(|index| mesh.vertices.get(index))
else {
return false;
};
let [v0, v1, v2] = [v0.position, v1.position, v2.position];
let face = cross3(sub3(v1, v0), sub3(v2, v0));
let facing = dot3(sub3(eye_world, v0), face) * f32::from(handedness);
facing.is_finite() && facing > 0.0
}
fn world_draw_range_index_count(
mesh: &VulkanStaticMesh,
range: &VulkanStaticDrawRange,
original_index_count: u32,
diffuse_alpha: f32,
eye_world: [f32; 3],
native_msh_handedness: Option<i8>,
) -> u32 {
if !(diffuse_alpha > 0.0) {
return 0;
}
if native_msh_handedness.is_some_and(|handedness| {
!native_msh_first_triangle_visible(mesh, range, eye_world, handedness)
}) {
return 0;
}
original_index_count
}
fn configure_world_material_categories(
mesh: &mut VulkanStaticMesh,
materials: &[VulkanStaticMaterial],
world_range_indices: &[usize],
) -> Result<(), String> {
for &range_index in world_range_indices {
let range = mesh
.draw_ranges
.get_mut(range_index)
.ok_or_else(|| "world material range index is out of bounds".to_string())?;
let material = materials
.get(usize::from(range.material_index))
.ok_or_else(|| {
format!(
"world material selector {} is out of bounds",
range.material_index
)
})?;
range.pipeline_state = range
.pipeline_state
.with_material_category(material.pipeline_category)
.ok_or_else(|| {
format!(
"world material selector {} has unsupported native pipeline category {}",
range.material_index, material.pipeline_category
)
})?;
}
Ok(())
}
fn register_material_range(
bindings: &mut [MaterialAnimationBinding],
material_index: usize,
range_index: usize,
) {
for binding in bindings {
if let Some(variant) = binding.variants.iter_mut().find(|variant| {
variant
.phase_material_indices
.get(binding.initial_phase_index)
.copied()
== Some(material_index)
}) {
variant.range_indices.push(range_index);
return;
}
}
}
fn find_material_animation_variant(
bindings: &[MaterialAnimationBinding],
material_index: usize,
) -> Option<(usize, Vec<usize>)> {
bindings
.iter()
.enumerate()
.find_map(|(binding_index, binding)| {
binding.variants.iter().find_map(|variant| {
(variant
.phase_material_indices
.get(binding.initial_phase_index)
.copied()
== Some(material_index))
.then(|| (binding_index, variant.phase_material_indices.clone()))
})
})
}
fn selected_material_variant_index(
variant: &MaterialVariantSet,
phase_index: usize,
) -> Option<usize> {
variant.phase_material_indices.get(phase_index).copied()
}
fn remap_material_animation_ranges(
bindings: &mut [MaterialAnimationBinding],
remap: &[usize],
) -> Result<(), String> {
for binding in bindings {
for variant in &mut binding.variants {
for range_index in &mut variant.range_indices {
*range_index = *remap
.get(*range_index)
.ok_or_else(|| "MAT0 draw range remap is out of bounds".to_string())?;
}
}
}
Ok(())
}
fn phase_texture_name(phase: &MaterialPhase) -> Option<ResourceName> {
let length = phase
.texture_raw
.iter()
.position(|byte| *byte == 0)
.unwrap_or(phase.texture_raw.len());
(length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec()))
}
fn phase_uv_transforms_from_assets(
document: &Mat0Document,
assets: &MissionAssets,
) -> Vec<[f32; 4]> {
document
.phases
.iter()
.map(|phase| {
phase_texture_name(phase)
.and_then(|name| {
assets
.textures
.iter()
.find(|texture| {
texture.usage == PreparedTextureUsage::Diffuse
&& texture.source.name == name
})
.and_then(|texture| {
page_uv_transform(
texture.texm.width(),
texture.texm.height(),
&texture.texm.page_rects(),
i8::from_ne_bytes([phase.parameters[17]]),
)
.ok()
})
})
.unwrap_or([0.0, 0.0, 1.0, 1.0])
})
.collect()
}
fn phase_uv_transforms_from_standalone(
loader: &mut StandaloneWearMaterialLoader,
wear_path: &Path,
material_index: u16,
document: &Mat0Document,
) -> Result<Vec<[f32; 4]>, String> {
document
.phases
.iter()
.enumerate()
.map(|(phase_index, phase)| {
if phase_texture_name(phase).is_none() {
return Ok([0.0, 0.0, 1.0, 1.0]);
}
loader
.load(
wear_path,
material_index,
u16::try_from(phase_index).unwrap_or(u16::MAX),
)
.map(|(_, prepared)| prepared.page_uv_transform)
})
.collect()
}
#[derive(Clone, Copy, Debug)]
struct EnvironmentSkyLayer {
kind: SkyPassKind,
stage: Option<usize>,
secondary_stage: Option<usize>,
material_index: usize,
diffuse_alpha: f32,
static_directional_rgb: [f32; 3],
static_additive_rgb: [f32; 3],
translation_z: f32,
range: EnvironmentGpuRange,
}
#[derive(Clone, Copy, Debug)]
struct EnvironmentSpriteLayer {
row: usize,
material_index: usize,
diffuse_alpha: f32,
flare_index: Option<usize>,
range: EnvironmentGpuRange,
}
/// Fixed-capacity GPU allocations for the CPU-owned sky and environment
/// output. The Vulkan renderer only receives one shared vertex update and
/// smaller draw counts each frame; descriptor images and index allocations
/// stay stable while the camera and weather move.
#[derive(Clone, Debug)]
struct EnvironmentGpuScene {
material_animations: Vec<MaterialAnimationBinding>,
sky_layers: Vec<EnvironmentSkyLayer>,
sprites: Vec<EnvironmentSpriteLayer>,
rain: EnvironmentGpuRange,
snow: EnvironmentGpuRange,
lightning: EnvironmentGpuRange,
lightning_material_index: usize,
}
fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> LegacyPipelineState {
LegacyPipelineState {
blend,
depth,
..LegacyPipelineState::default()
}
}
fn projected_shadow_pipeline_state() -> LegacyPipelineState {
LegacyPipelineState {
blend: LegacyBlendMode::SourceAlpha,
depth: LegacyDepthMode::TestReadOnly,
depth_bias: LegacyDepthBias::ProjectedDecal,
..LegacyPipelineState::default()
}
}
fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex {
environment_vertex_with_alpha(position, color, 1.0, uv)
}
fn environment_vertex_with_alpha(
position: [f32; 3],
color: [f32; 3],
alpha: f32,
uv: [f32; 2],
) -> VulkanStaticVertex {
environment_vertex_with_alpha_normal(position, color, alpha, uv, [0.0, 0.0, 1.0])
}
fn environment_vertex_with_alpha_normal(
position: [f32; 3],
color: [f32; 3],
alpha: f32,
uv: [f32; 2],
normal: [f32; 3],
) -> VulkanStaticVertex {
VulkanStaticVertex {
position,
color,
normal,
uv,
detail_uv: [0.0, 0.0],
overlay_alpha: alpha.clamp(0.0, 1.0),
}
}
fn sky_normal(raw: [i8; 3]) -> [f32; 3] {
// Ngi32's cloud-light decode uses the signed-byte scale 1/128 and keeps
// the authored vector length. Renormalizing (or using 1/127) changes the
// edge values and shifts the native cloud shading.
[
f32::from(raw[0]) / 128.0,
f32::from(raw[1]) / 128.0,
f32::from(raw[2]) / 128.0,
]
}
fn solid_environment_texture(rgba: [u8; 4]) -> VulkanStaticTexture {
let mip = VulkanStaticTextureMip {
width: 1,
height: 1,
rgba8: rgba.to_vec(),
};
VulkanStaticTexture {
width: 1,
height: 1,
rgba8: mip.rgba8.clone(),
mip_levels: vec![mip],
}
}
fn vulkan_texture_from_rgba_mips(
mips: Vec<RgbaImage>,
label: &str,
) -> Result<VulkanStaticTexture, String> {
let first = mips
.first()
.ok_or_else(|| format!("{label} has no mip zero"))?;
Ok(VulkanStaticTexture {
width: first.width,
height: first.height,
rgba8: first.rgba8.clone(),
mip_levels: mips
.into_iter()
.map(|mip| VulkanStaticTextureMip {
width: mip.width,
height: mip.height,
rgba8: mip.rgba8,
})
.collect(),
})
}
fn append_environment_material(
materials: &mut Vec<VulkanStaticMaterial>,
material_names: &mut HashMap<String, usize>,
material_assets: &HashMap<String, EnvironmentMaterialAsset>,
material_animations: &mut Vec<MaterialAnimationBinding>,
name: &str,
fallback_row: usize,
) -> Result<usize, String> {
if let Some(index) = material_names.get(name).copied() {
return Ok(index);
}
let asset = material_assets.get(name).ok_or_else(|| {
format!("environment material {name:?} is missing from resolved material.lib/MAT0 assets")
})?;
let phases = if asset.phases.is_empty() {
vec![asset.phase]
} else {
asset.phases.clone()
};
let first_material_index = materials.len();
let mut phase_material_indices = Vec::with_capacity(phases.len());
for (phase_index, phase) in phases.iter().enumerate() {
let index = materials.len();
let material_index = u16::try_from(index).map_err(|_| {
"environment exceeds the available 16-bit material selector space".to_string()
})?;
materials.push(VulkanStaticMaterial {
material_index,
pipeline_category: phase.pipeline_category,
texture: asset
.phase_textures
.get(phase_index)
.cloned()
.unwrap_or_else(|| asset.texture.clone()),
directional_rgb: phase.directional_rgb,
additive_rgb: phase.additive_rgb,
uv_transform: phase.page_uv_transform,
detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
overlay_texture: None,
overlay_detail_texture: None,
diffuse_alpha: phase.diffuse_alpha.clamp(0.0, 1.0),
overlay_diffuse_alpha: None,
overlay_directional_rgb: None,
overlay_additive_rgb: None,
specular_rgb: phase.specular_rgb,
specular_power: phase.power,
overlay_specular_rgb: None,
overlay_specular_power: None,
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_uv_transform: None,
overlay_detail_uv_transform: None,
// Native cloud draws carry shade/fog flags. The remaining sky rows
// are unlit overlays; all rows still retain their decoded normals.
unlit: fallback_row != 2,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: matches!(fallback_row, 0..=2),
screen_tl: ENVIRONMENT_SPRITE_ROWS.contains(&fallback_row),
});
phase_material_indices.push(index);
}
let document = asset.document.clone();
material_animations.push(MaterialAnimationBinding {
initial_phase_index: 0,
normalized_phase_override: None,
ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
pipeline_category: phases[0].pipeline_category,
}],
base: MaterialPhaseBinding {
document,
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: first_material_index as u32,
frozen_phase_index: None,
phase_uv_transforms: phases.iter().map(|phase| phase.page_uv_transform).collect(),
},
detail: None,
overlay: None,
overlay_detail: None,
stage_textures: None,
});
material_names.insert(name.to_owned(), first_material_index);
Ok(first_material_index)
}
fn prepare_effect_material(
root: &Path,
archive: &str,
name: &str,
materials: &mut Vec<VulkanStaticMaterial>,
cache: &mut HashMap<String, PreparedEffectMaterial>,
) -> Result<PreparedEffectMaterial, String> {
let key = format!("{archive}/{name}");
if let Some(prepared) = cache.get(&key) {
return Ok(prepared.clone());
}
let (first_mips, first_phase, document) =
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
root, archive, name, 0,
)
.map_err(|error| format!("resolve FX MAT0 {key}: {error}"))?;
if document.phases.is_empty() {
return Err(format!("FX MAT0 {key} has no phases"));
}
let mut phase_material_indices = Vec::with_capacity(document.phases.len());
let mut phase_uv_transforms = Vec::with_capacity(document.phases.len());
let mut phase_native_flag_40 = Vec::with_capacity(document.phases.len());
let mut phase_directional_rgb = Vec::with_capacity(document.phases.len());
let mut phase_additive_rgb = Vec::with_capacity(document.phases.len());
for phase_index in 0..document.phases.len() {
let (mips, phase) = if phase_index == 0 {
(first_mips.clone(), first_phase)
} else {
let phase_index_u16 = u16::try_from(phase_index)
.map_err(|_| format!("FX MAT0 {key} phase index exceeds u16"))?;
let (mips, phase, _document) =
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
root,
archive,
name,
phase_index_u16,
)
.map_err(|error| format!("resolve FX MAT0 {key} phase {phase_index}: {error}"))?;
(mips, phase)
};
let texture = vulkan_texture_from_rgba_mips(mips, "FX material")?;
let index = materials.len();
let material_index = u16::try_from(index).map_err(|_| {
"FX effects exceed the available 16-bit material selector space".to_string()
})?;
materials.push(VulkanStaticMaterial {
material_index,
pipeline_category: phase.pipeline_category,
texture,
directional_rgb: phase.directional_rgb,
additive_rgb: phase.additive_rgb,
uv_transform: phase.page_uv_transform,
detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
overlay_texture: None,
overlay_detail_texture: None,
// FX descriptor opacity is carried by each frame's vertex alpha.
// Keep MAT0 alpha at one so it does not multiply the opacity a
// second time.
diffuse_alpha: 1.0,
overlay_diffuse_alpha: None,
overlay_directional_rgb: None,
overlay_additive_rgb: None,
specular_rgb: phase.specular_rgb,
specular_power: phase.power,
overlay_specular_rgb: None,
overlay_specular_power: None,
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_uv_transform: None,
overlay_detail_uv_transform: None,
unlit: true,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
screen_tl: false,
});
phase_material_indices.push(index);
phase_uv_transforms.push(phase.page_uv_transform);
phase_native_flag_40.push(phase.native_flag_40);
phase_directional_rgb.push(phase.directional_rgb);
phase_additive_rgb.push(phase.additive_rgb);
}
let prepared = PreparedEffectMaterial {
initial_pipeline_category: materials[phase_material_indices[0]].pipeline_category,
document,
phase_material_indices,
phase_uv_transforms,
phase_native_flag_40,
phase_directional_rgb,
phase_additive_rgb,
};
cache.insert(key, prepared.clone());
Ok(prepared)
}
fn append_effect_material_animation(
prepared: &PreparedEffectMaterial,
range_index: usize,
bindings: &mut Vec<MaterialAnimationBinding>,
) -> usize {
let binding_index = bindings.len();
bindings.push(MaterialAnimationBinding {
initial_phase_index: 0,
normalized_phase_override: None,
ambient_fx_material_alpha: true,
variants: vec![MaterialVariantSet {
phase_material_indices: prepared.phase_material_indices.clone(),
range_indices: vec![range_index],
pipeline_category: prepared.initial_pipeline_category,
}],
base: MaterialPhaseBinding {
document: prepared.document.clone(),
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: u32::try_from(binding_index).unwrap_or(u32::MAX),
frozen_phase_index: None,
phase_uv_transforms: prepared.phase_uv_transforms.clone(),
},
detail: None,
overlay: None,
overlay_detail: None,
stage_textures: None,
});
binding_index
}
fn append_environment_gradient_material(
materials: &mut Vec<VulkanStaticMaterial>,
) -> Result<usize, String> {
let index = materials.len();
let material_index = u16::try_from(index).map_err(|_| {
"environment exceeds the available 16-bit material selector space".to_string()
})?;
materials.push(VulkanStaticMaterial {
material_index,
pipeline_category: 0,
texture: solid_environment_texture([255, 255, 255, 255]),
directional_rgb: [1.0; 3],
additive_rgb: [0.0; 3],
uv_transform: [0.0, 0.0, 1.0, 1.0],
detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
overlay_texture: None,
overlay_detail_texture: None,
diffuse_alpha: 1.0,
overlay_diffuse_alpha: None,
overlay_directional_rgb: None,
overlay_additive_rgb: None,
specular_rgb: [0.0; 3],
specular_power: 0,
overlay_specular_rgb: None,
overlay_specular_power: None,
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_uv_transform: None,
overlay_detail_uv_transform: None,
unlit: true,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: true,
screen_tl: false,
});
Ok(index)
}
fn shadow_atlas_texture() -> VulkanStaticTexture {
let rgba8 = vec![0_u8; 256 * 256 * 4];
VulkanStaticTexture {
width: 256,
height: 256,
rgba8: rgba8.clone(),
mip_levels: vec![VulkanStaticTextureMip {
width: 256,
height: 256,
rgba8,
}],
}
}
fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<usize, String> {
let material_index = u16::try_from(materials.len()).map_err(|_| {
"shadow material selector exceeds the available 16-bit material selector space".to_string()
})?;
let index = materials.len();
materials.push(VulkanStaticMaterial {
material_index,
pipeline_category: 0,
texture: shadow_atlas_texture(),
directional_rgb: [1.0; 3],
additive_rgb: [0.0; 3],
uv_transform: [0.0, 0.0, 1.0, 1.0],
detail_texture: Some(solid_environment_texture([255, 255, 255, 255])),
overlay_texture: None,
overlay_detail_texture: None,
diffuse_alpha: 1.0,
overlay_diffuse_alpha: None,
overlay_directional_rgb: None,
overlay_additive_rgb: None,
specular_rgb: [0.0; 3],
specular_power: 0,
overlay_specular_rgb: None,
overlay_specular_power: None,
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_uv_transform: None,
overlay_detail_uv_transform: None,
// Combiner mode 1 keeps the atlas alpha and the projected receiver's
// per-vertex alpha while bypassing world lighting/fog.
unlit: true,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
screen_tl: false,
});
Ok(index)
}
fn shadow_placeholder_vertices() -> Vec<VulkanStaticVertex> {
[
environment_vertex_with_alpha([0.0, 0.0, 0.0], [1.0; 3], 0.0, [0.0, 0.0]),
environment_vertex_with_alpha([1.0, 0.0, 0.0], [1.0; 3], 0.0, [1.0, 0.0]),
environment_vertex_with_alpha([0.0, 1.0, 0.0], [1.0; 3], 0.0, [0.0, 1.0]),
environment_vertex_with_alpha([1.0, 1.0, 0.0], [1.0; 3], 0.0, [1.0, 1.0]),
]
.to_vec()
}
fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
let normal = normalize3(cross3(
sub3(positions[1], positions[0]),
sub3(positions[2], positions[0]),
))
.unwrap_or([0.0, 0.0, 1.0]);
ShadowTriangle {
positions,
normal,
mask,
}
}
fn terrain_shadow_receivers(
terrain: &fparkan_terrain_format::LandMeshDocument,
) -> Result<Vec<ShadowTriangle>, String> {
let assignments = terrain
.face_lod_assignments()
.map_err(|error| format!("select terrain LOD0 shadow receivers: {error}"))?;
assignments
.into_iter()
.filter(|assignment| assignment.lod == TerrainLod::Lod0)
.map(|assignment| {
let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
format!(
"terrain LOD assignment references missing face {}",
assignment.face_index
)
})?;
let positions = source_terrain_face_positions(terrain, assignment.face_index)?;
if !positions.iter().flatten().all(|value| value.is_finite()) {
return Err("terrain shadow receiver contains a non-finite position".to_string());
}
Ok(shadow_triangle(positions, face.flags.0))
})
.collect()
}
fn source_terrain_face_positions(
terrain: &fparkan_terrain_format::LandMeshDocument,
face_index: usize,
) -> Result<[[f32; 3]; 3], String> {
let face = terrain
.faces
.get(face_index)
.ok_or_else(|| format!("terrain LOD assignment references missing face {face_index}"))?;
let [first, second, third] = face.vertices;
Ok([
terrain
.positions
.get(usize::from(first))
.copied()
.ok_or_else(|| {
format!("terrain face {face_index} references missing vertex {first}")
})?,
terrain
.positions
.get(usize::from(second))
.copied()
.ok_or_else(|| {
format!("terrain face {face_index} references missing vertex {second}")
})?,
terrain
.positions
.get(usize::from(third))
.copied()
.ok_or_else(|| {
format!("terrain face {face_index} references missing vertex {third}")
})?,
])
}
fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
mesh.indices
.chunks_exact(3)
.filter_map(|triangle| {
let positions = [
mesh.vertices
.get(usize::try_from(triangle[0]).ok()?)?
.position,
mesh.vertices
.get(usize::try_from(triangle[1]).ok()?)?
.position,
mesh.vertices
.get(usize::try_from(triangle[2]).ok()?)?
.position,
];
positions
.iter()
.flatten()
.all(|value| value.is_finite())
.then_some(shadow_triangle(positions, 0))
})
.collect()
}
fn static_mesh_shadow_triangles_in_index_range(
mesh: &VulkanStaticMesh,
first_index: usize,
index_count: usize,
) -> Result<Vec<ShadowTriangle>, String> {
if index_count % 3 != 0 {
return Err(format!(
"dynamic shadow index range {first_index}..{} is not triangle-aligned",
first_index.saturating_add(index_count)
));
}
let end = first_index
.checked_add(index_count)
.filter(|end| *end <= mesh.indices.len())
.ok_or_else(|| {
format!(
"dynamic shadow index range {first_index}..{} is out of bounds",
first_index.saturating_add(index_count)
)
})?;
mesh.indices[first_index..end]
.chunks_exact(3)
.map(|triangle| {
let positions =
triangle
.iter()
.map(|index| {
mesh.vertices
.get(usize::try_from(*index).map_err(|_| {
"dynamic shadow vertex index exceeds usize".to_string()
})?)
.map(|vertex| vertex.position)
.ok_or_else(|| {
format!("dynamic shadow vertex index {index} is out of bounds")
})
})
.collect::<Result<Vec<_>, _>>()?;
let positions: [[f32; 3]; 3] = positions
.try_into()
.map_err(|_| "dynamic shadow triangle did not have three vertices".to_string())?;
if !positions.iter().flatten().all(|value| value.is_finite()) {
return Err("dynamic shadow triangle contains a non-finite position".to_string());
}
Ok(shadow_triangle(positions, 0))
})
.collect()
}
fn refresh_dynamic_shadow_casters(
casters: &mut [ShadowCaster],
dynamic_casters: &[DynamicShadowCaster],
mesh: &VulkanStaticMesh,
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
mount_poses: &[Vec<Option<VulkanNodePose>>],
) -> Result<(), String> {
for dynamic in dynamic_casters {
let caster = casters.get_mut(dynamic.caster_index).ok_or_else(|| {
format!(
"dynamic shadow caster {} is outside the aggregate caster table",
dynamic.caster_index
)
})?;
let mut triangles = Vec::new();
for &range_index in &dynamic.component_draw_range_indices {
let range = mesh.draw_ranges.get(range_index).ok_or_else(|| {
format!(
"dynamic shadow caster {} references missing draw range {range_index}",
dynamic.caster_index
)
})?;
triangles.extend(static_mesh_shadow_triangles_in_index_range(
mesh,
usize::try_from(range.first_index)
.map_err(|_| "dynamic shadow draw range index exceeds usize".to_string())?,
usize::try_from(range.index_count)
.map_err(|_| "dynamic shadow draw range count exceeds usize".to_string())?,
)?);
}
if triangles.is_empty() {
return Err(format!(
"dynamic shadow caster {} lost its component triangles",
dynamic.caster_index
));
}
let component_spheres = dynamic
.component_spheres
.iter()
.filter_map(|source| {
let mount_pose = if source.mounted {
mount_poses
.get(dynamic.object_index)
.and_then(|items| items.get(source.component_index))
.copied()
.flatten()
} else {
None
};
if source.mounted && mount_pose.is_none() {
return None;
}
let node1_pose = if source.mounted {
pose_buffers
.get(dynamic.object_index)
.and_then(|items| items.get(source.component_index))
.and_then(Option::as_ref)
.and_then(|hierarchy| {
node38_pose_relative_to_root_from_hierarchy(hierarchy, 1)
})
} else {
None
};
mounted_shadow_sphere(source.center, source.radius, mount_pose, node1_pose)
})
.collect::<Vec<_>>();
if component_spheres.len() != dynamic.component_spheres.len() {
return Err(format!(
"dynamic shadow caster {} lost one or more component sphere poses",
dynamic.caster_index
));
}
let mut refreshed = aggregate_shadow_caster(
caster.id,
caster.native_kind,
dynamic.root_transform,
dynamic.owner_scale,
triangles,
&component_spheres,
)
.ok_or_else(|| {
format!(
"dynamic shadow caster {} could not be rebuilt from current component poses",
dynamic.caster_index
)
})?;
// shortcut: animated native sphere parity is unverified. Rebuild from
// the startup native bound, the current native bound, and current
// triangles so the result is conservative and independent of history.
refreshed.sphere = shadow_sphere_enclosing_current_geometry(
dynamic.initial_sphere,
refreshed.sphere,
&refreshed.triangles,
)?;
*caster = refreshed;
}
Ok(())
}
fn shadow_sphere_enclosing_current_geometry(
baseline: ShadowSphere,
current_native: ShadowSphere,
triangles: &[ShadowTriangle],
) -> Result<ShadowSphere, String> {
let mut sphere = baseline;
if !sphere.radius.is_finite()
|| sphere.radius < 0.0
|| !sphere.center.iter().all(|value| value.is_finite())
|| !current_native.radius.is_finite()
|| current_native.radius < 0.0
|| !current_native.center.iter().all(|value| value.is_finite())
{
return Err("animated shadow caster has an invalid bounding sphere".to_string());
}
let offset = sub3(current_native.center, sphere.center);
let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
if !distance.is_finite() {
return Err("animated shadow sphere distance is non-finite".to_string());
}
let required = distance + current_native.radius;
if !required.is_finite() {
return Err("animated shadow sphere radius is non-finite".to_string());
}
if current_native.radius >= distance + sphere.radius {
sphere = current_native;
} else if required > sphere.radius {
let next_radius = sphere.radius + 0.5 * (required - sphere.radius);
let shift = scale3(offset, (next_radius - sphere.radius) / distance);
sphere.center = add3(sphere.center, shift);
sphere.radius = next_radius;
}
for point in triangles.iter().flat_map(|triangle| triangle.positions) {
if !point.iter().all(|value| value.is_finite()) {
return Err("animated shadow triangle has a non-finite position".to_string());
}
let offset = sub3(point, sphere.center);
let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
if !distance.is_finite() {
return Err("animated shadow point distance is non-finite".to_string());
}
if distance <= sphere.radius {
continue;
}
let next_radius = sphere.radius + 0.5 * (distance - sphere.radius);
let center_shift = scale3(offset, (next_radius - sphere.radius) / distance);
sphere.center = add3(sphere.center, center_shift);
sphere.radius = next_radius;
}
Ok(sphere)
}
#[derive(Clone, Copy, Debug)]
struct ShadowComponentSphere {
center: [f32; 3],
radius: f32,
}
fn mounted_shadow_sphere(
center: [f32; 3],
radius: f32,
mount_pose: Option<VulkanNodePose>,
node1_pose: Option<VulkanNodePose>,
) -> Option<ShadowComponentSphere> {
if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= 0.0 {
return None;
}
let center = mount_pose.map_or(center, |parent_pose| {
// The native mounted-unit sphere query keeps node 1's accumulated
// rotation but replaces its translation with the accumulated parent
// translation. This is deliberately different from the render mount
// composition, which also carries node 1's translation.
let rotation = node1_pose.map_or(parent_pose.rotation, |node_pose| {
multiply_unit_quaternions(parent_pose.rotation, node_pose.rotation)
});
add3(
parent_pose.translation,
rotate_unit_vector(center, rotation),
)
});
center
.iter()
.all(|value| value.is_finite())
.then_some(ShadowComponentSphere { center, radius })
}
fn aggregate_shadow_caster(
id: u32,
native_kind: u32,
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
triangles: Vec<ShadowTriangle>,
component_spheres: &[ShadowComponentSphere],
) -> Option<ShadowCaster> {
if triangles.is_empty() {
return None;
}
let weight = component_spheres
.iter()
.map(|sphere| sphere.radius)
.sum::<f32>();
if !weight.is_finite() || weight <= f32::EPSILON {
return None;
}
// Native 9510 aggregates visible root/EXTO spheres by radius-weighted
// center, then encloses every component sphere around that center.
let aggregate_center = component_spheres.iter().fold([0.0; 3], |sum, sphere| {
add3(sum, scale3(sphere.center, sphere.radius))
});
let aggregate_center = scale3(aggregate_center, weight.recip());
let radius = component_spheres
.iter()
.map(|sphere| length3(sub3(sphere.center, aggregate_center)).unwrap_or(0.0) + sphere.radius)
.fold(0.0, f32::max);
let max_scale = scale.iter().copied().fold(f32::NEG_INFINITY, f32::max);
if !aggregate_center.iter().all(|value| value.is_finite())
|| !radius.is_finite()
|| radius <= f32::EPSILON
|| !max_scale.is_finite()
|| max_scale <= 0.0
{
return None;
}
// The native scale flag applies to the aggregate exactly once. The
// mission Euler transform then rotates/translates that scaled sphere.
let scaled_center = [
aggregate_center[0] * scale[0],
aggregate_center[1] * scale[1],
aggregate_center[2] * scale[2],
];
let center = transform.try_transform_scaled_point(scaled_center, [1.0; 3])?;
let radius = radius * max_scale;
if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() {
return None;
}
let world_axes = transform
.try_row_major()
.map(|matrix| {
[
[matrix[0], matrix[4], matrix[8]],
[matrix[1], matrix[5], matrix[9]],
[matrix[2], matrix[6], matrix[10]],
]
})
.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
Some(ShadowCaster {
id,
world_axes,
sphere: ShadowSphere { center, radius },
native_kind,
triangles,
})
}
fn shadow_lights(
sky_frame: Option<&SkyFrame<'_>>,
environment_frame: Option<&EnvironmentFrame>,
renderer: &VulkanSmokeRenderer,
control_point_lights: &[(VulkanPointLight, u32)],
) -> Vec<ShadowLight> {
let mut lights = Vec::new();
if let Some(sky_frame) = sky_frame {
lights.extend(sky_frame.directional_lights.into_iter().enumerate().map(
|(index, light)| {
ShadowLight::Directional {
direction: light.direction,
rgb: light.color,
active: light.active,
// CSun's constructor marks both the sun (slot 0) and
// moon (slot 2) as primary. ShadowReference selects the
// first one for kind 3 and accumulates both active
// primary directions for ordinary objects (kind 4).
flags: (index == 0 || index == 2)
.then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG)
.unwrap_or(0),
}
},
));
} else {
let uniforms = renderer.frame_uniforms();
lights.extend(
uniforms
.directional_lights
.into_iter()
.enumerate()
.map(|(index, light)| ShadowLight::Directional {
direction: light.direction,
rgb: light.rgb,
active: light.active,
flags: (index == 0 || index == 2)
.then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG)
.unwrap_or(0),
}),
);
}
if let Some(environment_frame) = environment_frame {
lights.extend(
environment_frame
.point_lights
.iter()
.map(|light| ShadowLight::Point {
position: light.position,
rgb: [light.color[0], light.color[1], light.color[2]],
range: light.range,
coefficients: light.attenuation,
active: light.active,
flags: 0,
}),
);
} else {
let point = renderer.frame_uniforms().point_light;
lights.push(ShadowLight::Point {
position: point.position,
rgb: point.rgb,
range: point.range,
coefficients: point.coefficients,
active: point.active,
flags: 0,
});
}
lights.extend(
control_point_lights
.iter()
.map(|(light, flags)| ShadowLight::Point {
position: light.position,
rgb: light.rgb,
range: light.range,
coefficients: light.coefficients,
active: light.active,
flags: *flags,
}),
);
lights
}
/// Native `Terrain30737` expands each caster sphere by 100 world units before
/// testing it against the six planes in the current world frustum. Filtering
/// here matters because `CShade` only accepts its first twenty candidates;
/// off-screen objects must not consume that budget.
const SHADOW_FRUSTUM_RADIUS_MARGIN: f32 = 100.0;
fn native_shadow_caster_visible(caster: &ShadowCaster, clip_from_world: [f32; 16]) -> bool {
let radius = caster.sphere.radius + SHADOW_FRUSTUM_RADIUS_MARGIN;
if !radius.is_finite() || radius < 0.0 || !caster.sphere.center.iter().all(|v| v.is_finite()) {
return false;
}
// The camera matrix is row-major D3D-style storage. A world point is
// transformed by p * M, so each clip coordinate uses one matrix column.
// The six Vulkan clip half-spaces are x+w, -x+w, y+w, -y+w, z, and w-z.
let planes = [
[
clip_from_world[0] + clip_from_world[3],
clip_from_world[4] + clip_from_world[7],
clip_from_world[8] + clip_from_world[11],
clip_from_world[12] + clip_from_world[15],
],
[
-clip_from_world[0] + clip_from_world[3],
-clip_from_world[4] + clip_from_world[7],
-clip_from_world[8] + clip_from_world[11],
-clip_from_world[12] + clip_from_world[15],
],
[
clip_from_world[1] + clip_from_world[3],
clip_from_world[5] + clip_from_world[7],
clip_from_world[9] + clip_from_world[11],
clip_from_world[13] + clip_from_world[15],
],
[
-clip_from_world[1] + clip_from_world[3],
-clip_from_world[5] + clip_from_world[7],
-clip_from_world[9] + clip_from_world[11],
-clip_from_world[13] + clip_from_world[15],
],
[
clip_from_world[2],
clip_from_world[6],
clip_from_world[10],
clip_from_world[14],
],
[
clip_from_world[3] - clip_from_world[2],
clip_from_world[7] - clip_from_world[6],
clip_from_world[11] - clip_from_world[10],
clip_from_world[15] - clip_from_world[14],
],
];
planes.into_iter().all(|plane| {
let normal_length =
(plane[0].mul_add(plane[0], plane[1].mul_add(plane[1], plane[2] * plane[2]))).sqrt();
if !normal_length.is_finite() || normal_length <= f32::EPSILON {
return false;
}
let distance = plane[0].mul_add(
caster.sphere.center[0],
plane[1].mul_add(
caster.sphere.center[1],
plane[2].mul_add(caster.sphere.center[2], plane[3]),
),
) / normal_length;
distance.is_finite() && distance > -radius
})
}
fn native_visible_shadow_casters(
casters: &[ShadowCaster],
clip_from_world: [f32; 16],
) -> Vec<ShadowCaster> {
casters
.iter()
.filter(|caster| native_shadow_caster_visible(caster, clip_from_world))
.cloned()
.collect()
}
fn append_shadow_frame_geometry(
mesh: &mut VulkanStaticMesh,
shadow_range: EnvironmentGpuRange,
shadow_base_vertex: usize,
shadow_base_index: usize,
frame: &ShadowFrame,
) -> Result<VulkanDynamicDrawRange, String> {
if shadow_base_vertex > mesh.vertices.len() || shadow_base_index > mesh.indices.len() {
return Err("shadow dynamic base is outside the shared mesh".to_string());
}
mesh.vertices.truncate(shadow_base_vertex);
mesh.indices.truncate(shadow_base_index);
let vertex_base = u32::try_from(mesh.vertices.len())
.map_err(|_| "shadow vertex base exceeds u32".to_string())?;
let first_index = u32::try_from(mesh.indices.len())
.map_err(|_| "shadow index base exceeds u32".to_string())?;
if frame.indices.iter().any(|&index| {
usize::try_from(index)
.ok()
.is_none_or(|index| index >= frame.vertices.len())
}) {
return Err("shadow frame index exceeds its vertex stream".to_string());
}
mesh.vertices.extend(frame.vertices.iter().map(|vertex| {
environment_vertex_with_alpha_normal(
vertex.position,
[1.0; 3],
vertex.alpha.clamp(0.0, 1.0),
vertex.uv,
vertex.normal,
)
}));
mesh.indices.extend(
frame
.indices
.iter()
.map(|&index| {
vertex_base
.checked_add(index)
.ok_or_else(|| "shadow index exceeds u32".to_string())
})
.collect::<Result<Vec<_>, _>>()?,
);
let index_count = u32::try_from(frame.indices.len())
.map_err(|_| "shadow index count exceeds u32".to_string())?;
if let Some(range) = mesh.draw_ranges.get_mut(shadow_range.range_index) {
range.first_index = first_index;
range.index_count = index_count;
}
Ok(VulkanDynamicDrawRange {
range_index: shadow_range.range_index,
first_index,
index_count,
})
}
fn shadow_atlas_texture_from_frame(frame: &ShadowFrame) -> VulkanStaticTexture {
VulkanStaticTexture {
width: 256,
height: 256,
rgba8: frame.atlas_rgba8.clone(),
mip_levels: vec![VulkanStaticTextureMip {
width: 256,
height: 256,
rgba8: frame.atlas_rgba8.clone(),
}],
}
}
fn append_environment_range(
mesh: &mut VulkanStaticMesh,
material_index: usize,
vertices: Vec<VulkanStaticVertex>,
local_indices: Vec<u32>,
pipeline_state: LegacyPipelineState,
) -> Result<EnvironmentGpuRange, String> {
if vertices.is_empty() || local_indices.is_empty() || !local_indices.len().is_multiple_of(3) {
return Err("environment geometry range is empty or not triangle-aligned".to_string());
}
let vertex_start = mesh.vertices.len();
let first_index = mesh.indices.len();
let material_index = u16::try_from(material_index)
.map_err(|_| "environment material selector exceeds u16".to_string())?;
let vertex_start_u32 = u32::try_from(vertex_start)
.map_err(|_| "environment vertex allocation exceeds u32".to_string())?;
for index in local_indices {
mesh.indices.push(
vertex_start_u32
.checked_add(index)
.ok_or_else(|| "environment index exceeds u32".to_string())?,
);
}
let index_count = u32::try_from(mesh.indices.len() - first_index)
.map_err(|_| "environment index allocation exceeds u32".to_string())?;
let vertex_capacity = vertices.len();
mesh.vertices.extend(vertices);
let first_index = u32::try_from(first_index)
.map_err(|_| "environment first index exceeds u32".to_string())?;
let range_index = mesh.draw_ranges.len();
mesh.draw_ranges
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
first_index,
index_count,
material_index,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state,
terrain_base_only: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
Ok(EnvironmentGpuRange {
range_index,
vertex_start,
vertex_capacity,
material_index: usize::from(material_index),
})
}
/// Reorders indexed draw segments without breaking the static mesh contract.
///
/// Draw ranges are required to cover one contiguous index buffer. Changing
/// only their vector order therefore is insufficient when a background pass
/// must run before mission geometry: the corresponding index segments have to
/// move with them and every range receives a new `first_index`.
fn reorder_draw_ranges(
mesh: &mut VulkanStaticMesh,
old_order: &[usize],
) -> Result<Vec<usize>, String> {
if old_order.len() != mesh.draw_ranges.len() {
return Err("draw-range reorder does not cover the whole mesh".to_string());
}
let mut seen = vec![false; mesh.draw_ranges.len()];
let old_ranges = mesh.draw_ranges.clone();
let old_indices = mesh.indices.clone();
let mut indices = Vec::with_capacity(old_indices.len());
let mut ranges = Vec::with_capacity(old_ranges.len());
let mut remap = vec![usize::MAX; old_ranges.len()];
for (new_index, &old_index) in old_order.iter().enumerate() {
if old_index >= old_ranges.len() || seen[old_index] {
return Err("draw-range reorder contains a duplicate or invalid range".to_string());
}
seen[old_index] = true;
let old_range = old_ranges[old_index];
let first = usize::try_from(old_range.first_index)
.map_err(|_| "draw-range first index exceeds addressable memory".to_string())?;
let count = usize::try_from(old_range.index_count)
.map_err(|_| "draw-range index count exceeds addressable memory".to_string())?;
let end = first
.checked_add(count)
.ok_or_else(|| "draw-range index segment overflows address space".to_string())?;
let segment = old_indices
.get(first..end)
.ok_or_else(|| "draw-range index segment is outside the mesh".to_string())?;
let first_index = u32::try_from(indices.len())
.map_err(|_| "reordered index buffer exceeds u32".to_string())?;
indices.extend_from_slice(segment);
let mut range = old_range;
range.first_index = first_index;
ranges.push(range);
remap[old_index] = new_index;
}
if seen.iter().any(|seen| !seen) || indices.len() != old_indices.len() {
return Err("draw-range reorder does not cover the whole index buffer".to_string());
}
mesh.indices = indices;
mesh.draw_ranges = ranges;
Ok(remap)
}
fn remap_dynamic_shadow_caster_draw_ranges(
dynamic_casters: &mut [DynamicShadowCaster],
range_remap: &[usize],
) -> Result<(), String> {
let remapped = dynamic_casters
.iter()
.map(|dynamic| {
dynamic
.component_draw_range_indices
.iter()
.map(|&range_index| {
range_remap.get(range_index).copied().ok_or_else(|| {
format!(
"dynamic shadow caster {} references missing pre-reorder draw range {range_index}",
dynamic.caster_index
)
})
})
.collect::<Result<Vec<_>, _>>()
})
.collect::<Result<Vec<_>, _>>()?;
for (dynamic, ranges) in dynamic_casters.iter_mut().zip(remapped) {
dynamic.component_draw_range_indices = ranges;
}
Ok(())
}
fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> {
let mut indices = Vec::with_capacity(
slot_count
.checked_mul(6)
.ok_or_else(|| "environment quad index count overflow".to_string())?,
);
for slot in 0..slot_count {
let base = u32::try_from(
slot.checked_mul(4)
.ok_or_else(|| "environment quad vertex count overflow".to_string())?,
)
.map_err(|_| "environment quad vertex count exceeds u32".to_string())?;
indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 1, base + 3]);
}
Ok(indices)
}
fn placeholder_quad_vertices(
camera: &PreviewCameraFrame,
slot_count: usize,
size: f32,
) -> Vec<VulkanStaticVertex> {
let center = add3(
camera.position,
scale3(camera.forward(), camera.far_plane * 0.5),
);
let right = scale3(camera.right(), size);
let up = scale3(camera.up(), size);
(0..slot_count)
.flat_map(|_| {
[
environment_vertex(sub3(sub3(center, right), up), [1.0; 3], [0.0, 1.0]),
environment_vertex(add3(sub3(center, up), right), [1.0; 3], [1.0, 1.0]),
environment_vertex(add3(sub3(center, right), up), [1.0; 3], [0.0, 0.0]),
environment_vertex(add3(add3(center, right), up), [1.0; 3], [1.0, 0.0]),
]
})
.collect()
}
fn screen_gradient_quad_vertices(
camera: &PreviewCameraFrame,
viewport: [f32; 2],
color: [f32; 3],
) -> [VulkanStaticVertex; 4] {
let width = viewport[0].max(1.0);
let height = viewport[1].max(1.0);
let aspect = width / height;
let fov = camera.vertical_fov;
// The quad is placed close to the camera and drawn with depth disabled.
// It therefore covers the complete projected viewport while the later
// dome, terrain, and object ranges provide their own depth-tested detail.
let distance = 1.0_f32;
let half_height = distance * (fov * 0.5).tan() * 1.05;
let half_width = half_height * aspect;
let center = add3(camera.position, scale3(camera.forward(), distance));
let right = scale3(camera.right(), half_width);
let up = scale3(camera.up(), half_height);
[
environment_vertex(sub3(sub3(center, right), up), color, [0.0, 1.0]),
environment_vertex(add3(sub3(center, up), right), color, [1.0, 1.0]),
environment_vertex(add3(sub3(center, right), up), color, [0.0, 0.0]),
environment_vertex(add3(add3(center, right), up), color, [1.0, 0.0]),
]
}
impl EnvironmentGpuScene {
fn transparent_background_range_indices(&self) -> Vec<usize> {
self.sky_layers
.iter()
.filter(|layer| {
matches!(
layer.kind,
SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds
)
})
.map(|layer| layer.range.range_index)
.collect()
}
fn fixed_range_indices(&self) -> HashSet<usize> {
self.sky_layers
.iter()
.map(|layer| layer.range.range_index)
.chain(self.sprites.iter().map(|sprite| sprite.range.range_index))
.chain([
self.rain.range_index,
self.snow.range_index,
self.lightning.range_index,
])
.collect()
}
fn new(
mesh: &mut VulkanStaticMesh,
native_handedness_by_range: &mut HashMap<usize, i8>,
type16_present_ranges: &mut HashSet<usize>,
materials: &mut Vec<VulkanStaticMaterial>,
preview_material_animations: &mut Vec<MaterialAnimationBinding>,
ambient_effect_slots: &mut [AmbientEffectGpuSlot],
dynamic_shadow_casters: &mut [DynamicShadowCaster],
sun_occlusion_range_indices: &mut Vec<usize>,
environment_materials: &SkyMaterials,
material_assets: &HashMap<String, EnvironmentMaterialAsset>,
sky_mesh: &SkyMesh,
camera: &PreviewCameraFrame,
shadow_range_index: &mut usize,
) -> Result<Self, String> {
let mut names = HashMap::new();
let mut material_animations = Vec::new();
let mut material_indices = [None; 9];
let fallback_names = SkyMaterials::default();
for row in 0..9 {
let name = environment_materials
.material_name(row)
.or_else(|| fallback_names.material_name(row))
.unwrap_or("ENVIRONMENT");
let index = append_environment_material(
materials,
&mut names,
material_assets,
&mut material_animations,
name,
row,
)?;
material_indices[row] = Some(index);
}
let gradient_material_index = append_environment_gradient_material(materials)?;
let nebula_material_index = material_indices[0]
.ok_or_else(|| "nebula material selector is unavailable".to_string())?;
let stars_material_index = material_indices[1]
.ok_or_else(|| "stars material selector is unavailable".to_string())?;
// Native sky mode 4 uses one descriptor set: TEX0 is the nebula and
// TEX1 is the independently animated stars texture whose alpha chooses
// the contribution. Keep the stars MAT0 binding as the nebula binding's
// detail stage; do not form a Cartesian product of the two phase rows.
let (nebula_binding_index, nebula_phase_materials) =
find_material_animation_variant(&material_animations, nebula_material_index)
.ok_or_else(|| {
format!("MAT0 sky material {nebula_material_index} has no phase variants")
})?;
let (stars_binding_index, stars_phase_materials) =
find_material_animation_variant(&material_animations, stars_material_index)
.ok_or_else(|| {
format!("MAT0 sky material {stars_material_index} has no phase variants")
})?;
if stars_phase_materials.is_empty() {
return Err("stars material has no phase variants".to_string());
}
let stars_descriptors = stars_phase_materials
.iter()
.map(|&material_index| {
let material = materials.get(material_index).ok_or_else(|| {
format!("stars material descriptor {material_index} is unavailable")
})?;
Ok((material.texture.clone(), material.uv_transform))
})
.collect::<Result<Vec<_>, String>>()?;
let stars_binding = material_animations
.get(stars_binding_index)
.ok_or_else(|| "stars material animation binding is unavailable".to_string())?
.base
.clone();
let stars_stage_textures = stars_descriptors
.iter()
.map(|(texture, _)| texture.clone())
.collect();
let stars_initial_uv = stars_descriptors
.first()
.map(|(_, uv_transform)| *uv_transform)
.unwrap_or([0.0, 0.0, 1.0, 1.0]);
for &material_index in &nebula_phase_materials {
let stars_texture = stars_descriptors
.first()
.map(|(texture, _)| texture.clone())
.ok_or_else(|| "stars material descriptor is unavailable".to_string())?;
let nebula = materials.get_mut(material_index).ok_or_else(|| {
format!("nebula material descriptor {material_index} is unavailable")
})?;
nebula.detail_texture = Some(stars_texture);
nebula.detail_uv_transform = stars_initial_uv;
nebula.sky_nebula_stars = true;
}
let nebula_binding = material_animations
.get_mut(nebula_binding_index)
.ok_or_else(|| "nebula material animation binding is unavailable".to_string())?;
nebula_binding.detail = Some(stars_binding);
nebula_binding.stage_textures = Some(MaterialStageTextures {
detail: Some(stars_stage_textures),
overlay: None,
overlay_detail: None,
// Force TEX1 to the independently sampled stars phase on the first
// frame, including when the initial star descriptor is already set.
applied_material_phases: HashMap::new(),
});
let lightning_material_index = append_environment_material(
materials,
&mut names,
material_assets,
&mut material_animations,
"env_lightning",
3,
)?;
let mut scene = Self {
material_animations,
sky_layers: Vec::with_capacity(4),
sprites: Vec::with_capacity(ENVIRONMENT_SPRITE_ROWS.len() + ENVIRONMENT_FLARE_SLOTS),
rain: EnvironmentGpuRange {
range_index: 0,
vertex_start: 0,
vertex_capacity: 0,
material_index: material_indices[8].unwrap_or(0),
},
snow: EnvironmentGpuRange {
range_index: 0,
vertex_start: 0,
vertex_capacity: 0,
material_index: material_indices[7].unwrap_or(0),
},
lightning: EnvironmentGpuRange {
range_index: 0,
vertex_start: 0,
vertex_capacity: 0,
material_index: lightning_material_index,
},
lightning_material_index,
};
let screen_range = append_environment_range(
mesh,
gradient_material_index,
screen_gradient_quad_vertices(camera, [16.0, 9.0], [1.0; 3]).to_vec(),
quad_indices(1)?,
world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::Disabled),
)?;
scene.sky_layers.push(EnvironmentSkyLayer {
kind: SkyPassKind::ScreenGradient,
stage: None,
secondary_stage: None,
material_index: gradient_material_index,
diffuse_alpha: 1.0,
static_directional_rgb: [1.0; 3],
static_additive_rgb: [0.0; 3],
translation_z: 0.0,
range: screen_range,
});
let nebula_material = materials
.get(nebula_material_index)
.ok_or_else(|| "nebula material descriptor is unavailable".to_string())?;
let nebula_vertices = sky_mesh
.vertices
.iter()
.map(|vertex| {
let color = vertex.color_rgba();
environment_vertex_with_alpha_normal(
add3(vertex.position, camera.position),
[color[0], color[1], color[2]],
color[3],
vertex.uv[0],
sky_normal(vertex.normal),
)
})
.collect();
let nebula_range = append_environment_range(
mesh,
nebula_material_index,
nebula_vertices,
sky_mesh.indices.clone(),
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
)?;
register_material_range(
&mut scene.material_animations,
nebula_material_index,
nebula_range.range_index,
);
scene.sky_layers.push(EnvironmentSkyLayer {
kind: SkyPassKind::NebulaStars,
stage: Some(0),
secondary_stage: Some(1),
material_index: nebula_material_index,
diffuse_alpha: nebula_material.diffuse_alpha,
static_directional_rgb: nebula_material.directional_rgb,
static_additive_rgb: nebula_material.additive_rgb,
translation_z: 0.0,
range: nebula_range,
});
let dome_vertices = sky_mesh
.vertices
.iter()
.map(|vertex| {
let color = vertex.color_rgba();
environment_vertex_with_alpha_normal(
add3(vertex.position, camera.position),
[color[0], color[1], color[2]],
color[3],
[0.0, 0.0],
sky_normal(vertex.normal),
)
})
.collect();
let dome_range = append_environment_range(
mesh,
gradient_material_index,
dome_vertices,
sky_mesh.indices.clone(),
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
)?;
scene.sky_layers.push(EnvironmentSkyLayer {
kind: SkyPassKind::DomeGradient,
stage: None,
secondary_stage: None,
material_index: gradient_material_index,
diffuse_alpha: 1.0,
static_directional_rgb: [1.0; 3],
static_additive_rgb: [0.0; 3],
translation_z: 0.0,
range: dome_range,
});
let clouds_material_index = material_indices[2]
.ok_or_else(|| "cloud material selector is unavailable".to_string())?;
let clouds_material = materials
.get(clouds_material_index)
.ok_or_else(|| "cloud material descriptor is unavailable".to_string())?;
let clouds_vertices = sky_mesh
.vertices
.iter()
.map(|vertex| {
let color = vertex.color_rgba();
environment_vertex_with_alpha_normal(
add3(add3(vertex.position, camera.position), [0.0, 0.0, -5000.0]),
[color[0], color[1], color[2]],
color[3],
vertex.uv[2],
sky_normal(vertex.normal),
)
})
.collect();
let clouds_range = append_environment_range(
mesh,
clouds_material_index,
clouds_vertices,
sky_mesh.indices.clone(),
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
)?;
register_material_range(
&mut scene.material_animations,
clouds_material_index,
clouds_range.range_index,
);
scene.sky_layers.push(EnvironmentSkyLayer {
kind: SkyPassKind::Clouds,
stage: Some(2),
secondary_stage: None,
material_index: clouds_material_index,
diffuse_alpha: clouds_material.diffuse_alpha,
static_directional_rgb: clouds_material.directional_rgb,
static_additive_rgb: clouds_material.additive_rgb,
translation_z: -5000.0,
range: clouds_range,
});
let sprite_size = (camera.far_plane * 0.01).max(1.0);
for row in ENVIRONMENT_SPRITE_ROWS {
let material_index = material_indices[row]
.ok_or_else(|| format!("sky sprite row {row} has no material selector"))?;
let range = append_environment_range(
mesh,
material_index,
placeholder_quad_vertices(camera, 1, sprite_size),
quad_indices(1)?,
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
)?;
register_material_range(
&mut scene.material_animations,
material_index,
range.range_index,
);
scene.sprites.push(EnvironmentSpriteLayer {
row,
material_index,
diffuse_alpha: materials
.get(material_index)
.map_or(1.0, |material| material.diffuse_alpha),
flare_index: None,
range,
});
}
for flare_index in 0..ENVIRONMENT_FLARE_SLOTS {
let row = SKY_FLARE_ROWS[flare_index];
let material_index = material_indices[row]
.ok_or_else(|| format!("sky flare row {row} has no material selector"))?;
let range = append_environment_range(
mesh,
material_index,
placeholder_quad_vertices(camera, 1, sprite_size),
quad_indices(1)?,
world_pipeline_state(LegacyBlendMode::Additive, LegacyDepthMode::TestReadOnly),
)?;
register_material_range(
&mut scene.material_animations,
material_index,
range.range_index,
);
scene.sprites.push(EnvironmentSpriteLayer {
row,
material_index,
diffuse_alpha: materials
.get(material_index)
.map_or(1.0, |material| material.diffuse_alpha),
flare_index: Some(flare_index),
range,
});
}
let particle_indices = quad_indices(ENVIRONMENT_PARTICLE_SLOTS)?;
scene.rain = append_environment_range(
mesh,
material_indices[8]
.ok_or_else(|| "rain material selector is unavailable".to_string())?,
placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25),
particle_indices.clone(),
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
)?;
register_material_range(
&mut scene.material_animations,
scene.rain.material_index,
scene.rain.range_index,
);
scene.snow = append_environment_range(
mesh,
material_indices[7]
.ok_or_else(|| "snow material selector is unavailable".to_string())?,
placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25),
particle_indices,
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
)?;
register_material_range(
&mut scene.material_animations,
scene.snow.material_index,
scene.snow.range_index,
);
scene.lightning = append_environment_range(
mesh,
lightning_material_index,
placeholder_quad_vertices(camera, 1, sprite_size),
quad_indices(1)?,
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
)?;
register_material_range(
&mut scene.material_animations,
scene.lightning.material_index,
scene.lightning.range_index,
);
// Preserve the native sky order while keeping the indexed mesh
// contiguous. The screen gradient must be the first draw, and the
// cloud dome follows all directional sprites. Reordering the range
// metadata alone would leave first_index values pointing into the old
// segment order, so move each index segment together with its range.
let range_for_kind = |kind: SkyPassKind| {
scene
.sky_layers
.iter()
.find(|layer| layer.kind == kind)
.map(|layer| layer.range.range_index)
};
let screen_index = range_for_kind(SkyPassKind::ScreenGradient)
.ok_or_else(|| "screen gradient range was not allocated".to_string())?;
let nebula_index = range_for_kind(SkyPassKind::NebulaStars)
.ok_or_else(|| "nebula sky range was not allocated".to_string())?;
let dome_index = range_for_kind(SkyPassKind::DomeGradient)
.ok_or_else(|| "dome gradient range was not allocated".to_string())?;
let cloud_index = range_for_kind(SkyPassKind::Clouds)
.ok_or_else(|| "cloud sky range was not allocated".to_string())?;
let sprite_indices = scene
.sprites
.iter()
.map(|sprite| sprite.range.range_index)
.collect::<Vec<_>>();
if sprite_indices.is_empty() {
return Err("sky sprite ranges were not allocated".to_string());
}
let rain_index = scene.rain.range_index;
let snow_index = scene.snow.range_index;
let lightning_index = scene.lightning.range_index;
let environment_indices = [
screen_index,
nebula_index,
dome_index,
cloud_index,
rain_index,
snow_index,
lightning_index,
*shadow_range_index,
]
.into_iter()
.chain(sprite_indices.iter().copied())
.collect::<HashSet<_>>();
let old_range_count = mesh.draw_ranges.len();
let mut old_order = Vec::with_capacity(old_range_count);
old_order.push(screen_index);
old_order.extend((0..old_range_count).filter(|index| {
!environment_indices.contains(index)
&& *index != screen_index
&& *index != nebula_index
&& *index != dome_index
&& *index != cloud_index
}));
old_order.push(*shadow_range_index);
old_order.extend([nebula_index, dome_index, cloud_index]);
old_order.extend(sprite_indices.iter().copied());
old_order.extend([rain_index, snow_index, lightning_index]);
let remap = reorder_draw_ranges(mesh, &old_order)?;
let mut remapped_handedness = HashMap::with_capacity(native_handedness_by_range.len());
for (old_range_index, handedness) in std::mem::take(native_handedness_by_range) {
let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| {
format!("native MSH draw range remap is missing range {old_range_index}")
})?;
remapped_handedness.insert(new_range_index, handedness);
}
*native_handedness_by_range = remapped_handedness;
let mut remapped_type16_present_ranges =
HashSet::with_capacity(type16_present_ranges.len());
for old_range_index in std::mem::take(type16_present_ranges) {
let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| {
format!("type-16 MSH draw range remap is missing range {old_range_index}")
})?;
remapped_type16_present_ranges.insert(new_range_index);
}
*type16_present_ranges = remapped_type16_present_ranges;
remap_dynamic_shadow_caster_draw_ranges(dynamic_shadow_casters, &remap)?;
for layer in &mut scene.sky_layers {
layer.range.range_index = *remap
.get(layer.range.range_index)
.ok_or_else(|| "sky draw range remap is out of bounds".to_string())?;
}
for sprite in &mut scene.sprites {
sprite.range.range_index = *remap
.get(sprite.range.range_index)
.ok_or_else(|| "sky sprite draw range remap is out of bounds".to_string())?;
}
scene.rain.range_index = *remap
.get(scene.rain.range_index)
.ok_or_else(|| "rain draw range remap is out of bounds".to_string())?;
scene.snow.range_index = *remap
.get(scene.snow.range_index)
.ok_or_else(|| "snow draw range remap is out of bounds".to_string())?;
scene.lightning.range_index = *remap
.get(scene.lightning.range_index)
.ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?;
remap_material_animation_ranges(&mut scene.material_animations, &remap)?;
remap_material_animation_ranges(preview_material_animations, &remap)?;
for slot in ambient_effect_slots {
slot.range.range_index = *remap
.get(slot.range.range_index)
.ok_or_else(|| "ambient FX draw range remap is out of bounds".to_string())?;
for range in &mut slot.additional_particle_ranges {
range.range_index = *remap.get(range.range_index).ok_or_else(|| {
"ambient FX particle draw range remap is out of bounds".to_string()
})?;
}
}
for range_index in sun_occlusion_range_indices {
*range_index = *remap
.get(*range_index)
.ok_or_else(|| "sun occlusion range remap is out of bounds".to_string())?;
}
*shadow_range_index = *remap
.get(*shadow_range_index)
.ok_or_else(|| "shadow draw range remap is out of bounds".to_string())?;
Ok(scene)
}
fn update(
&mut self,
mesh: &mut VulkanStaticMesh,
renderer: &mut VulkanSmokeRenderer,
material_update: &MaterialFrameUpdate,
sky_frame: &SkyFrame,
sky_mesh: &SkyMesh,
environment_frame: &EnvironmentFrame,
camera: &PreviewCameraFrame,
viewport: [f32; 2],
terrain: &TerrainWorld,
sun_occlusion_range_indices: &[usize],
) -> Result<(), String> {
let active_materials = &material_update.active_materials;
// Resolve the native glare once for the frame. CSky feeds the
// boosted primary color back into the light manager and uses the
// positive boost delta for both the screen-gradient horizon floor and
// the dome palette clamp.
let projected_sun =
project_direction_pixels(camera, sky_frame.sun.geometry_direction(), viewport)
.unwrap_or([f32::NAN; 2]);
let optics = sky_frame
.sun_optics(SunVisibility {
projected_sun,
viewport,
view_forward: camera.forward(),
// Flares use the same terrain/object ray query as the native
// visibility gate. The sun sprite itself deliberately stays
// independent of this test, matching the native layer's
// `requires_occlusion` split.
unoccluded: native_sun_unoccluded_with_world(
terrain,
mesh,
sun_occlusion_range_indices,
camera,
sky_frame.sun.geometry_direction(),
),
})
.ok();
let glare_rgb_delta = optics.map_or([0.0; 3], |optics| {
std::array::from_fn(|index| {
(optics.primary_color[index] - sky_frame.sun.color[index]).max(0.0)
})
});
let gradient = sky_frame.screen_gradient(camera.native_sky_heading(), glare_rgb_delta);
if let Some(optics) = optics {
let mut uniforms = renderer.frame_uniforms();
if sky_frame.sun.active {
uniforms.directional_lights[0].rgb = optics.primary_color;
}
renderer
.set_frame_uniforms(uniforms)
.map_err(|error| format!("update glare light uniforms: {error}"))?;
}
let gradient_rgba = gradient.color.rgba();
for layer in &self.sky_layers {
let range = layer.range;
let destination = mesh
.vertices
.get_mut(range.vertex_start..range.vertex_start + range.vertex_capacity)
.ok_or_else(|| "sky vertex allocation is out of bounds".to_string())?;
let pass_frame = sky_frame
.passes
.iter()
.find(|pass| pass.kind == layer.kind)
.ok_or_else(|| format!("sky frame is missing {:?} pass", layer.kind))?;
match layer.kind {
SkyPassKind::ScreenGradient => {
if destination.len() != 4 {
return Err("screen gradient vertex allocation changed".to_string());
}
destination.copy_from_slice(&screen_gradient_quad_vertices(
camera,
viewport,
[gradient_rgba[0], gradient_rgba[1], gradient_rgba[2]],
));
}
SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds => {
if destination.len() != sky_mesh.vertices.len() {
return Err(
"sky mesh vertex count changed after GPU allocation".to_string()
);
}
for (target, source) in destination.iter_mut().zip(&sky_mesh.vertices) {
let source_color = match layer.kind {
// Native mode 4 receives a white DIFFUSE and
// chooses nebula/stars through TEX1 alpha.
SkyPassKind::NebulaStars | SkyPassKind::Clouds => [1.0; 4],
SkyPassKind::DomeGradient => {
gradient.clamp_dome_color(source.color).rgba()
}
SkyPassKind::ScreenGradient => unreachable!(),
};
target.position = add3(
add3(source.position, camera.position),
[0.0, 0.0, layer.translation_z],
);
target.color = [source_color[0], source_color[1], source_color[2]];
target.uv = layer
.stage
.and_then(|stage| source.uv.get(stage).copied())
.unwrap_or([0.0, 0.0]);
target.detail_uv = layer
.secondary_stage
.and_then(|stage| source.uv.get(stage).copied())
.unwrap_or([0.0, 0.0]);
target.normal = sky_normal(source.normal);
target.overlay_alpha = source_color[3];
}
}
}
let material_index = active_materials
.get(&range.range_index)
.map(|state| state.material_index)
.unwrap_or(layer.material_index);
if !active_materials.contains_key(&range.range_index) {
renderer
.set_material_alphas(material_index, layer.diffuse_alpha.clamp(0.0, 1.0), 0.0)
.map_err(|error| format!("update sky material alpha: {error}"))?;
}
if layer.kind == SkyPassKind::Clouds {
let directional_rgb = pass_frame
.material_color
.unwrap_or(layer.static_directional_rgb);
renderer
.set_material_lighting(
material_index,
directional_rgb,
layer.static_additive_rgb,
)
.map_err(|error| format!("update cloud material lighting: {error}"))?;
}
renderer
.set_draw_range_index_count(
range.range_index,
if layer.kind == SkyPassKind::ScreenGradient {
6
} else {
u32::try_from(sky_mesh.indices.len())
.map_err(|_| "sky index count exceeds u32".to_string())?
},
)
.map_err(|error| format!("update sky draw range: {error}"))?;
}
// Flares share their source material row, so their packed alpha lives
// in each vertex. Upload the shared phase alpha once before emitting
// the twelve fixed-capacity ranges.
let mut flare_materials = HashMap::new();
for sprite in &self.sprites {
if sprite.flare_index.is_some()
&& !active_materials.contains_key(&sprite.range.range_index)
{
flare_materials.insert(sprite.material_index, sprite.diffuse_alpha);
}
}
for (material_index, diffuse_alpha) in flare_materials {
renderer
.set_material_alphas(material_index, diffuse_alpha.clamp(0.0, 1.0), 0.0)
.map_err(|error| format!("update sky flare alpha: {error}"))?;
}
for sprite in &self.sprites {
let mut active = false;
let mut vertices = None;
let mut material_alpha = 0.0;
if let Some(flare_index) = sprite.flare_index {
if let Some(flare) =
optics.and_then(|optics| optics.flares.get(flare_index).copied())
{
let direction = screen_ray_from_pixels(camera, flare.center_pixels, viewport);
active = flare.active && direction.is_some() && flare.color[3] > 0.0;
if let Some(direction) = direction {
vertices = Some(sprite_quad_vertices_pixels(
camera,
direction,
(camera.far_plane * 0.5).max(camera.near_plane * 2.0),
flare.half_size_pixels,
flare.half_size_pixels,
viewport,
[flare.color[0], flare.color[1], flare.color[2]],
flare.color[3],
flare.uv,
));
}
}
} else if let Some(frame) = sprite_frame_for_row(sky_frame, sprite.row) {
let projected = project_direction_pixels(camera, frame.direction, viewport);
active = frame.active
&& frame.intensity > 0.0
&& projected.is_some_and(|center| {
center[0] >= 0.0
&& center[0] <= viewport[0]
&& center[1] >= 0.0
&& center[1] <= viewport[1]
});
if active {
if let Ok([half_width, half_height]) = sprite_half_size_pixels(
frame.size_values,
viewport,
horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)),
) {
if ENVIRONMENT_SPRITE_ROWS.contains(&sprite.row) {
if let Some(center_pixels) = projected {
vertices = Some(screen_tl_quad_vertices_pixels(
center_pixels,
half_width,
half_height,
viewport,
frame.color,
frame.alpha,
frame.uv,
));
} else {
active = false;
}
} else {
vertices = Some(sprite_quad_vertices_pixels(
camera,
frame.direction,
(camera.far_plane * 0.5).max(camera.near_plane * 2.0),
half_width,
half_height,
viewport,
frame.color,
frame.alpha,
frame.uv,
));
}
} else {
active = false;
}
}
material_alpha = if active {
// The packed sprite alpha is carried by the vertex. The
// MAT0 phase alpha remains the material multiplier;
// intensity controls lifecycle visibility, not a second
// alpha fade.
sprite.diffuse_alpha
} else {
0.0
};
}
if active {
if let Some(vertices) = vertices {
let destination = mesh
.vertices
.get_mut(sprite.range.vertex_start..sprite.range.vertex_start + 4)
.ok_or_else(|| {
"sky sprite vertex allocation is out of bounds".to_string()
})?;
destination.copy_from_slice(&vertices);
} else {
active = false;
}
}
if sprite.flare_index.is_none() {
let material_index = active_materials
.get(&sprite.range.range_index)
.map(|state| state.material_index)
.unwrap_or(sprite.material_index);
// The animation update above has already restored the
// sampled phase alpha for an active animated material. A
// hidden sprite still needs an explicit zero, while a static
// sprite uses its authored material alpha when visible.
let should_write_alpha =
!active || !active_materials.contains_key(&sprite.range.range_index);
if should_write_alpha {
renderer
.set_material_alphas(material_index, material_alpha.clamp(0.0, 1.0), 0.0)
.map_err(|error| format!("update sky sprite alpha: {error}"))?;
}
}
renderer
.set_draw_range_index_count(sprite.range.range_index, if active { 6 } else { 0 })
.map_err(|error| format!("update sky sprite draw range: {error}"))?;
}
let mut rain_count = 0usize;
let mut snow_count = 0usize;
let mut lightning = None;
for primitive in &environment_frame.primitives {
match primitive {
EnvironmentPrimitive::Particle {
kind,
color,
screen,
..
} => {
let (range, slot) = match kind {
PrecipitationKind::Rain if rain_count < ENVIRONMENT_PARTICLE_SLOTS => {
let slot = rain_count;
rain_count += 1;
(self.rain, slot)
}
PrecipitationKind::Snow if snow_count < ENVIRONMENT_PARTICLE_SLOTS => {
let slot = snow_count;
snow_count += 1;
(self.snow, slot)
}
_ => continue,
};
let vertices =
precipitation_quad_vertices(*kind, screen, *color, camera, viewport);
let start = range.vertex_start + slot * 4;
let destination = mesh.vertices.get_mut(start..start + 4).ok_or_else(|| {
"precipitation vertex allocation is out of bounds".to_string()
})?;
destination.copy_from_slice(&vertices);
}
EnvironmentPrimitive::Lightning(bolt) if lightning.is_none() => {
lightning = Some(bolt);
}
EnvironmentPrimitive::Lightning(_) => {}
}
}
renderer
.set_draw_range_index_count(
self.rain.range_index,
u32::try_from(rain_count * 6)
.map_err(|_| "rain draw count exceeds u32".to_string())?,
)
.map_err(|error| format!("update rain draw range: {error}"))?;
renderer
.set_draw_range_index_count(
self.snow.range_index,
u32::try_from(snow_count * 6)
.map_err(|_| "snow draw count exceeds u32".to_string())?,
)
.map_err(|error| format!("update snow draw range: {error}"))?;
let lightning_alpha = if let Some(bolt) = lightning {
let vertices = lightning_quad_vertices(bolt);
let destination = mesh
.vertices
.get_mut(self.lightning.vertex_start..self.lightning.vertex_start + 4)
.ok_or_else(|| "lightning vertex allocation is out of bounds".to_string())?;
destination.copy_from_slice(&vertices);
bolt.intensity.clamp(0.0, 1.0)
} else {
0.0
};
let lightning_material_index = active_materials
.get(&self.lightning.range_index)
.map(|state| state.material_index)
.unwrap_or(self.lightning_material_index);
renderer
.set_material_alphas(lightning_material_index, lightning_alpha, 0.0)
.map_err(|error| format!("update lightning material alpha: {error}"))?;
renderer
.set_draw_range_index_count(
self.lightning.range_index,
if lightning.is_some() { 6 } else { 0 },
)
.map_err(|error| format!("update lightning draw range: {error}"))?;
Ok(())
}
}
fn sprite_frame_for_row<'frame, 'materials>(
sky_frame: &'frame SkyFrame<'materials>,
row: usize,
) -> Option<SkySpriteFrame<'materials>> {
sky_frame
.sprites
.iter()
.copied()
.find(|sprite| sprite.row == row)
}
fn horizontal_fov(camera: &PreviewCameraFrame, aspect: f32) -> f32 {
let aspect = if aspect.is_finite() && aspect > 0.0 {
aspect
} else {
16.0 / 9.0
};
2.0 * ((camera.vertical_fov * 0.5).tan() * aspect).atan()
}
/// Tests the camera-to-sun segment against the validated mission terrain.
///
/// The native CSun caller starts a half-unit in front of the camera along the
/// normalized geometry direction, then queries the finite segment ending at
/// the sun center. Terrain source positions and captured-camera positions both
/// use the `Land.msh` coordinate units, so the ray is queried without a Z
/// conversion. Its parameter remains normalized to the finite segment and is
/// therefore independent of world distance.
#[cfg(test)]
fn native_sun_unoccluded(
terrain: &TerrainWorld,
camera: &PreviewCameraFrame,
direction: [f32; 3],
) -> bool {
let empty_mesh = VulkanStaticMesh {
vertices: Vec::new(),
indices: Vec::new(),
draw_ranges: Vec::new(),
};
native_sun_unoccluded_with_world(terrain, &empty_mesh, &[], camera, direction)
}
fn native_sun_unoccluded_with_world(
terrain: &TerrainWorld,
world_mesh: &VulkanStaticMesh,
sun_occlusion_range_indices: &[usize],
camera: &PreviewCameraFrame,
direction: [f32; 3],
) -> bool {
// Native CSun requests object kinds 1, 3, 4, and 10 (the aggregate
// object mask is 0x41A) and excludes full surface flag 0x20. The preview
// range list is built from roots whose raw mission kind is one of those
// four eligible kinds; fixed atmosphere ranges are remapped out before
// this call.
let Some(direction) = normalize3(direction) else {
return false;
};
let sun_center = add3(
camera.position,
scale3(
direction,
(camera.far_plane * 0.5).max(camera.near_plane * 2.0),
),
);
let start = add3(camera.position, scale3(direction, 0.5));
let segment = sub3(sun_center, start);
if !sun_center
.iter()
.chain(start.iter())
.chain(segment.iter())
.all(|value| value.is_finite())
{
return false;
}
if dot3(segment, segment) <= f32::EPSILON {
return true;
}
if let Ok(Some(hit)) = terrain.raycast_excluding(
start,
segment,
FullSurfaceMask(0),
FullSurfaceMask(0x0000_0020),
) {
if hit.distance.is_finite() && hit.distance <= 1.0 {
return false;
}
}
!world_mesh_sun_occluded(world_mesh, sun_occlusion_range_indices, start, segment)
}
fn world_mesh_sun_occluded(
mesh: &VulkanStaticMesh,
range_indices: &[usize],
origin: [f32; 3],
direction: [f32; 3],
) -> bool {
for &range_index in range_indices {
let Some(range) = mesh.draw_ranges.get(range_index).copied() else {
continue;
};
// The native world query receives the compact exclusion bit 0x8,
// which is the world-space representation of full surface flag 0x20.
if range.batch_flags & 0x0008 != 0 {
continue;
}
let Ok(first_index) = usize::try_from(range.first_index) else {
continue;
};
let Ok(index_count) = usize::try_from(range.index_count) else {
continue;
};
let Some(indices) = mesh
.indices
.get(first_index..first_index.saturating_add(index_count))
else {
continue;
};
for triangle in indices.chunks_exact(3) {
let Ok(a) = usize::try_from(triangle[0]) else {
continue;
};
let Ok(b) = usize::try_from(triangle[1]) else {
continue;
};
let Ok(c) = usize::try_from(triangle[2]) else {
continue;
};
let (Some(&a), Some(&b), Some(&c)) = (
mesh.vertices.get(a),
mesh.vertices.get(b),
mesh.vertices.get(c),
) else {
continue;
};
if segment_intersects_triangle(origin, direction, a.position, b.position, c.position) {
return true;
}
}
}
false
}
fn segment_intersects_triangle(
origin: [f32; 3],
direction: [f32; 3],
a: [f32; 3],
b: [f32; 3],
c: [f32; 3],
) -> bool {
let edge1 = sub3(b, a);
let edge2 = sub3(c, a);
let pvec = cross3(direction, edge2);
let determinant = dot3(edge1, pvec);
if !determinant.is_finite() || determinant.abs() <= f32::EPSILON {
return false;
}
let inverse = determinant.recip();
let tvec = sub3(origin, a);
let u = dot3(tvec, pvec) * inverse;
if !u.is_finite() || !(0.0..=1.0).contains(&u) {
return false;
}
let qvec = cross3(tvec, edge1);
let v = dot3(direction, qvec) * inverse;
if !v.is_finite() || v < 0.0 || u + v > 1.0 {
return false;
}
let distance = dot3(edge2, qvec) * inverse;
distance.is_finite() && (0.0..=1.0).contains(&distance)
}
/// Replays Terrain's native packet callback: a draw range is keyed by the
/// camera distance to its first indexed vertex.
fn first_indexed_vertex_distance(
mesh: &VulkanStaticMesh,
range_index: usize,
camera_position: [f32; 3],
) -> Result<f32, String> {
let range: VulkanStaticDrawRange = *mesh
.draw_ranges
.get(range_index)
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
let first_index = usize::try_from(range.first_index)
.map_err(|_| "world draw range first index is not addressable".to_string())?;
let vertex_index =
usize::try_from(*mesh.indices.get(first_index).ok_or_else(|| {
"world draw range first index is outside the index buffer".to_string()
})?)
.map_err(|_| "world draw range vertex index is not addressable".to_string())?;
let vertex = mesh
.vertices
.get(vertex_index)
.ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?;
let delta = sub3(vertex.position, camera_position);
let distance = dot3(delta, delta).sqrt();
distance
.is_finite()
.then_some(distance)
.ok_or_else(|| "world draw range sort distance is non-finite".to_string())
}
fn project_direction_pixels(
camera: &PreviewCameraFrame,
direction: [f32; 3],
viewport: [f32; 2],
) -> Option<[f32; 2]> {
let direction = normalize3(direction)?;
let width = viewport[0];
let height = viewport[1];
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return None;
}
let depth = dot3(camera.forward(), direction);
if !depth.is_finite() || depth <= 1.0e-4 {
return None;
}
let aspect = width / height;
let half_vertical = (camera.vertical_fov * 0.5).tan();
let half_horizontal = half_vertical * aspect;
let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal);
let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical);
let pixels = [(ndc_x * 0.5 + 0.5) * width, (0.5 - ndc_y * 0.5) * height];
pixels
.iter()
.all(|value| value.is_finite())
.then_some(pixels)
}
fn screen_ray_from_pixels(
camera: &PreviewCameraFrame,
pixels: [f32; 2],
viewport: [f32; 2],
) -> Option<[f32; 3]> {
if !pixels.iter().all(|value| value.is_finite())
|| !viewport.iter().all(|value| value.is_finite())
|| viewport[0] <= 0.0
|| viewport[1] <= 0.0
{
return None;
}
let aspect = viewport[0] / viewport[1];
let half_vertical = (camera.vertical_fov * 0.5).tan();
let half_horizontal = half_vertical * aspect;
let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0;
let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0;
normalize3(add3(
add3(
camera.forward(),
scale3(camera.right(), ndc_x * half_horizontal),
),
scale3(camera.up(), ndc_y * half_vertical),
))
}
fn sprite_quad_vertices_pixels(
camera: &PreviewCameraFrame,
direction: [f32; 3],
distance: f32,
half_width_pixels: f32,
half_height_pixels: f32,
viewport: [f32; 2],
color: [f32; 3],
alpha: f32,
uv: [[f32; 2]; 4],
) -> [VulkanStaticVertex; 4] {
let direction = normalize3(direction).unwrap_or(camera.forward());
let center = add3(camera.position, scale3(direction, distance));
let depth = dot3(camera.forward(), direction)
.mul_add(distance, 0.0)
.max(camera.near_plane.max(0.001));
let width = viewport[0].max(1.0);
let height = viewport[1].max(1.0);
let aspect = width / height;
let vertical_fov = camera.vertical_fov;
let horizontal_fov_radians = horizontal_fov(camera, aspect);
let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width;
let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height;
let right = scale3(
camera.right(),
half_width_pixels.max(0.0) * world_per_pixel_x,
);
let up = scale3(camera.up(), half_height_pixels.max(0.0) * world_per_pixel_y);
// Native sun and flare UVs are ordered left-top, left-bottom, right-bottom,
// right-top. The shared index order is bottom-left, bottom-right, top-left,
// top-right.
let uv = [uv[1], uv[2], uv[0], uv[3]];
[
environment_vertex_with_alpha(sub3(sub3(center, right), up), color, alpha, uv[0]),
environment_vertex_with_alpha(add3(sub3(center, up), right), color, alpha, uv[1]),
environment_vertex_with_alpha(add3(sub3(center, right), up), color, alpha, uv[2]),
environment_vertex_with_alpha(add3(add3(center, right), up), color, alpha, uv[3]),
]
}
fn screen_tl_quad_vertices_pixels(
center_pixels: [f32; 2],
half_width_pixels: f32,
half_height_pixels: f32,
viewport: [f32; 2],
color: [f32; 3],
alpha: f32,
uv: [[f32; 2]; 4],
) -> [VulkanStaticVertex; 4] {
let width = viewport[0].max(1.0);
let height = viewport[1].max(1.0);
let half_width = half_width_pixels.max(0.0);
let half_height = half_height_pixels.max(0.0);
let pixel_corners = [
[
center_pixels[0] - half_width,
center_pixels[1] + half_height,
],
[
center_pixels[0] + half_width,
center_pixels[1] + half_height,
],
[
center_pixels[0] - half_width,
center_pixels[1] - half_height,
],
[
center_pixels[0] + half_width,
center_pixels[1] - half_height,
],
];
// Positive-height Vulkan viewports map NDC -Y to top-down screen pixels.
let uv = [uv[1], uv[2], uv[0], uv[3]];
std::array::from_fn(|index| {
let pixel = pixel_corners[index];
let position = [
pixel[0] * (2.0 / width) - 1.0,
pixel[1] * (2.0 / height) - 1.0,
0.999,
];
environment_vertex_with_alpha(position, color, alpha, uv[index])
})
}
fn precipitation_quad_vertices(
kind: PrecipitationKind,
screen: &ScreenBillboard,
color: [f32; 4],
camera: &PreviewCameraFrame,
viewport: [f32; 2],
) -> [VulkanStaticVertex; 4] {
let rgb = [color[0], color[1], color[2]];
let depths = match kind {
PrecipitationKind::Rain => [
screen.tail_depth,
screen.tail_depth,
screen.head_depth,
screen.head_depth,
],
PrecipitationKind::Snow => [screen.head_depth; 4],
};
std::array::from_fn(|index| {
environment_vertex_with_alpha(
world_from_ndc(camera, screen.corners[index], depths[index], viewport),
rgb,
color[3],
screen.uv[index],
)
})
}
fn world_from_ndc(
camera: &PreviewCameraFrame,
ndc: [f32; 2],
depth: f32,
viewport: [f32; 2],
) -> [f32; 3] {
let depth = if depth.is_finite() {
depth.max(camera.near_plane.max(0.001))
} else {
camera.near_plane.max(0.001)
};
let aspect = viewport[0].max(1.0) / viewport[1].max(1.0);
let half_vertical = (camera.vertical_fov * 0.5).tan();
let half_horizontal = half_vertical * aspect;
let offset = add3(
scale3(camera.right(), ndc[0] * half_horizontal),
scale3(camera.up(), ndc[1] * half_vertical),
);
add3(
camera.position,
scale3(add3(camera.forward(), offset), depth),
)
}
fn lightning_quad_vertices(
bolt: &fparkan_fx::environment::LightningBolt,
) -> [VulkanStaticVertex; 4] {
std::array::from_fn(|index| {
environment_vertex(bolt.quad.vertices[index], [1.0; 3], bolt.quad.uv[index])
})
}
/// CPU atmosphere state shared by the interactive preview and the renderer's
/// per-frame light/fog block. The sky mesh and weather primitives stay on the
/// same update clock, so camera movement cannot leave environment audio or
/// lighting one frame behind.
struct DynamicEnvironment {
schedule: TypedAtmosphere,
sky: SkySystem,
environment: EnvironmentSystem,
materials: SkyMaterials,
material_assets: HashMap<String, EnvironmentMaterialAsset>,
schedule_offset_seconds: f32,
time_seconds: f32,
fixed_schedule_seconds: Option<f32>,
fixed_phase_reported: bool,
fixed_sky_uv_ms: Option<u32>,
}
struct DynamicEnvironmentFrame {
atmosphere: AtmosphereFrame,
sky_mesh: SkyMesh,
environment_frame: EnvironmentFrame,
absolute_time_seconds: f32,
}
fn environment_camera_for_preview(
camera: &PreviewCameraFrame,
aspect: f32,
viewport: [f32; 2],
) -> FxCamera {
FxCamera::with_projection(
camera.position,
camera.forward(),
camera.right(),
camera.up(),
camera.vertical_fov,
aspect,
)
.with_viewport(viewport)
}
struct SceneFrameUpdate {
clock: FrameClockSample,
camera: PreviewCameraFrame,
viewport: [f32; 2],
aspect: f32,
control_point_lights: Vec<(VulkanPointLight, u32)>,
control_vulkan_lights: Vec<VulkanPointLight>,
effect_updates: Vec<AmbientEffectGeometryUpdate>,
world_sort_keys: Vec<(usize, f32)>,
ambient_sort_keys: Vec<(usize, f32)>,
material_update: MaterialFrameUpdate,
environment_material_update: Option<MaterialFrameUpdate>,
environment: Option<DynamicEnvironmentFrame>,
}
impl DynamicEnvironment {
fn update_cpu(
&mut self,
dt_seconds: f32,
clock_ms: u32,
camera: &PreviewCameraFrame,
aspect: f32,
viewport: [f32; 2],
mut audio: Option<&mut audio::GameAudio>,
) -> Result<DynamicEnvironmentFrame, String> {
let dt_seconds = if dt_seconds.is_finite() {
dt_seconds.max(0.0)
} else {
0.0
};
let (time_seconds, absolute_time_seconds) = environment_sample_time(
self.time_seconds,
self.schedule_offset_seconds,
dt_seconds,
self.fixed_schedule_seconds,
);
self.time_seconds = time_seconds;
if let Some(seconds) = self.fixed_schedule_seconds {
if !self.fixed_phase_reported {
eprintln!(
"CAPTURE_PHASE_SAMPLE absolute_seconds={seconds:.6} schedule_frozen=true"
);
self.fixed_phase_reported = true;
}
}
let atmosphere = self.schedule.sample(absolute_time_seconds);
let mut sky_mesh = self.sky.mesh().clone();
sky_mesh.update_uvs(self.fixed_sky_uv_ms.unwrap_or(clock_ms));
let sky_frame =
SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds);
sky_mesh.update_colors(&sky_frame.sky);
if let Some(audio) = audio.as_mut() {
let primary_rgb = if sky_frame.sun.active {
sky_frame.sun.color
} else if sky_frame.moon.active {
sky_frame.moon.color
} else {
[0.0; 3]
};
audio
.update_ambience(dt_seconds, primary_rgb)
.map_err(|error| format!("update ambient variation: {error}"))?;
}
let fx_camera = environment_camera_for_preview(camera, aspect, viewport);
let environment_frame = self.environment.update_atmosphere_with_materials(
dt_seconds,
&atmosphere,
&self.materials,
fx_camera,
);
if let Some(audio) = audio.as_mut() {
for sound in &environment_frame.sounds {
audio
.handle_sound_event(sound)
.map_err(|error| format!("play environment sound: {error}"))?;
}
}
Ok(DynamicEnvironmentFrame {
atmosphere,
sky_mesh,
environment_frame,
absolute_time_seconds,
})
}
}
fn load_environment(
root: &Path,
mission: &str,
atmosphere_seconds: Option<f32>,
hold_schedule_phase: bool,
) -> Result<Option<DynamicEnvironment>, String> {
let mission_dir = mission_asset_directory(root, mission)?;
let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else {
return Ok(None);
};
let sky_bytes = std::fs::read(&sky_path)
.map_err(|error| format!("read atmosphere {}: {error}", sky_path.display()))?;
let schedule = TypedAtmosphere::parse(&sky_bytes)
.map_err(|error| format!("parse atmosphere {}: {error}", sky_path.display()))?;
let sky_wea_path = match find_sibling_file(&sky_path, "sky.wea") {
Some(path) => Some(path),
None => find_mission_file(&mission_dir, "sky.wea")?,
}
.ok_or_else(|| format!("selected atmosphere {} has no sky.wea", sky_path.display()))?;
let materials = SkyMaterials::parse(
&std::fs::read(&sky_wea_path)
.map_err(|error| format!("read sky materials {}: {error}", sky_wea_path.display()))?,
)
.map_err(|error| format!("parse sky materials {}: {error}", sky_wea_path.display()))?;
let mut sky_material_loader = StandaloneWearMaterialLoader::new(root);
let mut material_assets =
load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root(
root,
&sky_wea_path,
0,
)
.map_err(|error| {
format!(
"resolve sky material WEAR {}: {error}",
sky_wea_path.display()
)
})?
.into_iter()
.enumerate()
.map(
|(material_index, (name, mips, phase, document))| -> Result<_, String> {
let initial_phase = phase;
let mut phase_textures = Vec::with_capacity(document.phases.len());
let mut phases = Vec::with_capacity(document.phases.len());
for phase_index in 0..document.phases.len() {
let phase_index_u16 = u16::try_from(phase_index).unwrap_or(u16::MAX);
let (phase_mips, sampled_phase) = if phase_index == 0 {
(mips.clone(), initial_phase)
} else {
sky_material_loader
.load(
&sky_wea_path,
u16::try_from(material_index).unwrap_or(u16::MAX),
phase_index_u16,
)
.map_err(|error| {
format!("resolve sky material phase {phase_index}: {error}")
})?
};
phase_textures.push(vulkan_texture_from_rgba_mips(phase_mips, "sky material")?);
phases.push(sampled_phase);
}
let texture = phase_textures
.first()
.cloned()
.ok_or_else(|| "sky material has no MAT0 phases".to_string())?;
Ok((
String::from_utf8_lossy(&name).into_owned(),
EnvironmentMaterialAsset {
texture,
phase: initial_phase,
document,
phase_textures,
phases,
},
))
},
)
.collect::<Result<HashMap<_, _>, String>>()?;
let effect_bytes = load_resource_entry_bytes_from_root(root, "effects.rlb", "env_lightning")
.map_err(|error| format!("load effects.rlb/env_lightning: {error}"))?;
let lightning_effect = decode_env_lightning_fxid(effect_bytes)
.map_err(|error| format!("decode effects.rlb/env_lightning: {error}"))?;
if !lightning_effect
.visual
.archive
.eq_ignore_ascii_case("material.lib")
{
return Err(format!(
"env_lightning visual archive must be material.lib, got {:?}",
lightning_effect.visual.archive
));
}
let (lightning_mips, lightning_phase, lightning_document) =
load_material_name_texture_rgba8_and_phase_with_document_from_root(
root,
&lightning_effect.visual.name,
0,
)
.map_err(|error| {
format!(
"resolve env_lightning MAT0 {}: {error}",
lightning_effect.visual.name
)
})?;
let mut lightning_phase_textures = vec![vulkan_texture_from_rgba_mips(
lightning_mips,
"env_lightning material",
)?];
let mut lightning_phases = vec![lightning_phase];
for phase_index in 1..lightning_document.phases.len() {
let (phase_mips, phase, _document) =
load_material_name_texture_rgba8_and_phase_with_document_from_root(
root,
&lightning_effect.visual.name,
u16::try_from(phase_index).unwrap_or(u16::MAX),
)
.map_err(|error| {
format!(
"resolve env_lightning MAT0 phase {phase_index} {}: {error}",
lightning_effect.visual.name
)
})?;
lightning_phase_textures.push(vulkan_texture_from_rgba_mips(
phase_mips,
"env_lightning material",
)?);
lightning_phases.push(phase);
}
let lightning_texture = lightning_phase_textures
.first()
.cloned()
.ok_or_else(|| "env_lightning MAT0 has no phases".to_string())?;
material_assets.insert(
lightning_effect.visual.name.clone(),
EnvironmentMaterialAsset {
texture: lightning_texture,
phase: lightning_phase,
document: lightning_document,
phase_textures: lightning_phase_textures,
phases: lightning_phases,
},
);
let initial_offset_seconds = schedule.initial_offset_seconds() as f32;
let sky = SkySystem::new(Default::default(), materials.clone())
.map_err(|error| format!("build sky geometry: {error}"))?;
let time_seconds = atmosphere_seconds
.map(|seconds| seconds - initial_offset_seconds)
.unwrap_or(0.0);
let fixed_schedule_seconds = if hold_schedule_phase {
atmosphere_seconds
} else {
None
};
let mut environment = EnvironmentSystem::new(0x4650_4152_4B41_4E_u64);
environment.set_lightning_effect(lightning_effect);
Ok(Some(DynamicEnvironment {
schedule,
sky,
environment,
materials,
material_assets,
schedule_offset_seconds: initial_offset_seconds,
time_seconds,
fixed_schedule_seconds,
fixed_phase_reported: false,
fixed_sky_uv_ms: None,
}))
}
fn environment_sample_time(
time_seconds: f32,
schedule_offset_seconds: f32,
dt_seconds: f32,
fixed_schedule_seconds: Option<f32>,
) -> (f32, f32) {
if let Some(absolute_seconds) = fixed_schedule_seconds {
return (time_seconds, absolute_seconds);
}
let next_time_seconds = if time_seconds.is_finite() {
time_seconds + dt_seconds
} else {
0.0
};
(
next_time_seconds,
schedule_offset_seconds + next_time_seconds,
)
}
/// Returns the host directory containing the selected mission's assets.
/// Mission keys are the same relative paths accepted by the runtime VFS; the
/// host root is only used here for the small set of loose mission-local files
/// that are not archive entries (`sky.ske` and `sky.wea`).
fn mission_asset_directory(root: &Path, mission: &str) -> Result<PathBuf, String> {
let mission_path = Path::new(mission);
if mission_path.is_absolute() {
return Err("mission environment path must be relative to --root".to_string());
}
let directory = mission_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let directory = root.join(directory);
if !directory.is_dir() {
return Err(format!(
"selected mission asset directory does not exist: {}",
directory.display()
));
}
Ok(directory)
}
/// Finds one mission-local loose environment file. A direct sibling wins;
/// nested `env`, `environment`, or `planet` layouts are accepted only when
/// they contain a single matching file, so a second unrelated candidate never
/// changes the selected mission silently.
fn find_mission_file(root: &Path, name: &str) -> Result<Option<PathBuf>, String> {
let direct = root.join(name);
if direct.is_file() {
return Ok(Some(direct));
}
let mut stack = vec![root.to_path_buf()];
let mut matches = Vec::new();
while let Some(directory) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
stack.push(path);
} else if file_type.is_file()
&& path
.file_name()
.is_some_and(|file_name| file_name.eq_ignore_ascii_case(name))
{
matches.push(path);
}
}
}
matches.sort();
match matches.as_slice() {
[] => Ok(None),
[path] => Ok(Some(path.clone())),
_ => Err(format!(
"selected mission has multiple {name} files: {}",
matches
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)),
}
}
fn find_sibling_file(source: &Path, name: &str) -> Option<PathBuf> {
source
.parent()
.map(|parent| parent.join(name))
.filter(|path| path.is_file())
}
fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[left[0] + right[0], left[1] + right[1], left[2] + right[2]]
}
fn add3_in_place(target: &mut [f32; 3], value: [f32; 3]) {
*target = add3(*target, value);
}
fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[left[0] - right[0], left[1] - right[1], left[2] - right[2]]
}
fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] {
[value[0] * factor, value[1] * factor, value[2] * factor]
}
fn multiply3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[left[0] * right[0], left[1] * right[1], left[2] * right[2]]
}
fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
}
fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[
left[1] * right[2] - left[2] * right[1],
left[2] * right[0] - left[0] * right[2],
left[0] * right[1] - left[1] * right[0],
]
}
fn length3(value: [f32; 3]) -> Option<f32> {
let length = dot3(value, value).sqrt();
length
.is_finite()
.then_some(length)
.filter(|length| *length > f32::EPSILON)
}
fn normalize3(value: [f32; 3]) -> Option<[f32; 3]> {
let inverse = length3(value)?.recip();
Some(scale3(value, inverse))
}
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
for column in 0..4 {
result[row * 4 + column] = (0..4)
.map(|inner| left[row * 4 + inner] * right[inner * 4 + column])
.sum();
}
}
result
}
struct StaticPreviewScene {
mesh: VulkanStaticMesh,
dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
native_handedness_by_range: HashMap<usize, i8>,
type16_present_ranges: HashSet<usize>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
control_point_light_slots: Vec<ControlEffectSlot>,
control_sound_slots: Vec<ControlSoundSlot>,
unsupported_control_sounds: usize,
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
building_collision_bindings: Vec<BuildingCollisionBinding>,
control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
dynamic_shadow_casters: Vec<DynamicShadowCaster>,
sun_occlusion_range_indices: Vec<usize>,
shadow_casters: Vec<ShadowCaster>,
shadow_receivers: Vec<ShadowTriangle>,
shadow_material_index: usize,
terrain: Arc<TerrainWorld>,
camera: VulkanStaticCamera,
view_camera: PreviewCameraFrame,
captured_viewport: Option<CapturedViewport>,
free_camera: Option<FreeFlightCamera>,
camera_mode: &'static str,
mesh_components: usize,
terrain_components: usize,
}
#[derive(Clone)]
struct ControlAnimationInstance {
control: PreparedComponentControl,
model: ModelAsset,
gate_values: Vec<Option<f32>>,
state: ControlInstanceState,
last_sampled_node_times: Vec<Option<f32>>,
reported_unsupported_fallback: bool,
}
#[derive(Clone)]
struct DynamicComponentGeometry {
model: ModelAsset,
root_transform: LegacyIron3dEulerTransform,
owner_scale: [f32; 3],
parent_mount: Option<(usize, usize)>,
vertex_start: usize,
vertex_count: usize,
}
#[derive(Clone)]
struct BuildingCollisionBinding {
object_index: usize,
component_index: usize,
model_source: String,
model: Arc<ModelAsset>,
root_transform: LegacyIron3dEulerTransform,
owner_scale: [f32; 3],
mounted: bool,
selected_nodes: Vec<(NodeId, SlotId)>,
}
fn building_collision_binding(
object_index: usize,
component_index: usize,
model_source: String,
model: Arc<ModelAsset>,
root_transform: LegacyIron3dEulerTransform,
owner_scale: [f32; 3],
mounted: bool,
) -> Result<BuildingCollisionBinding, String> {
let context = || {
format!(
"building collision object {object_index} component {component_index} model {model_source}"
)
};
if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() {
return Err(format!("{} has no complete Node38 pose data", context()));
}
if model.triangle_descriptors.is_none() {
return Err(format!("{} has no type-7 collision descriptors", context()));
}
if root_transform.try_row_major().is_none()
|| !owner_scale.iter().all(|value| value.is_finite())
|| owner_scale.contains(&0.0)
{
return Err(format!("{} has an invalid world transform", context()));
}
let first_node = usize::from(mounted);
let mut selected_nodes = Vec::new();
for node_index in first_node..model.node_count {
let node = NodeId(
u32::try_from(node_index)
.map_err(|_| format!("{} node index exceeds u32", context()))?,
);
let Some(slot) = selected_slot(&model, node, Lod(0), Group(0)) else {
continue;
};
let slot_index = usize::try_from(slot.0)
.map_err(|_| format!("{} node {} has an invalid slot id", context(), node.0))?;
let slot_data = model.slots.get(slot_index).ok_or_else(|| {
format!(
"{} node {} selects missing slot {}",
context(),
node.0,
slot.0
)
})?;
let batch_start = usize::from(slot_data.batch_start);
let batch_end = batch_start
.checked_add(usize::from(slot_data.batch_count))
.ok_or_else(|| format!("{} node {} batch range overflows", context(), node.0))?;
let batches = model.batches.get(batch_start..batch_end).ok_or_else(|| {
format!(
"{} node {} batch range {batch_start}..{batch_end} is invalid",
context(),
node.0
)
})?;
if batches.iter().any(|batch| batch.index_count != 0) {
selected_nodes.push((node, slot));
}
}
Ok(BuildingCollisionBinding {
object_index,
component_index,
model_source,
model,
root_transform,
owner_scale,
mounted,
selected_nodes,
})
}
fn building_collision_nodes(
binding: &BuildingCollisionBinding,
pose_buffer: Option<&NodePoseBuffer>,
mount_pose: Option<VulkanNodePose>,
) -> Result<Vec<BuildingMeshNode>, String> {
let context = || {
format!(
"building collision object {} component {} model {}",
binding.object_index, binding.component_index, binding.model_source
)
};
if binding.mounted != mount_pose.is_some() {
return Err(format!(
"{} has {} mount pose, expected {}",
context(),
if mount_pose.is_some() { "a" } else { "no" },
if binding.mounted { "a" } else { "no" }
));
}
binding
.selected_nodes
.iter()
.map(|(node, slot)| {
let node_index =
usize::try_from(node.0).map_err(|_| format!("{} node id is invalid", context()))?;
let pose = match (pose_buffer, binding.mounted) {
(Some(hierarchy), true) => {
node38_pose_relative_to_root_from_hierarchy(hierarchy, node_index)
}
(Some(hierarchy), false) => node38_pose_from_hierarchy(hierarchy, node_index),
(None, true) => node38_pose_relative_to_root(&binding.model, None, node_index),
(None, false) => node38_pose(&binding.model, None, node_index),
}
.ok_or_else(|| format!("{} node {} has no current Node38 pose", context(), node.0))?;
let world_from_node = component_node_world_matrix(
binding.root_transform,
binding.owner_scale,
mount_pose,
pose,
)
.ok_or_else(|| format!("{} node {} has an invalid world matrix", context(), node.0))?;
Ok(BuildingMeshNode {
node: *node,
slot: *slot,
world_from_node,
})
})
.collect()
}
fn component_node_world_matrix(
root_transform: LegacyIron3dEulerTransform,
owner_scale: [f32; 3],
mount_pose: Option<VulkanNodePose>,
node_pose: VulkanNodePose,
) -> Option<[f32; 16]> {
if !owner_scale
.iter()
.chain(node_pose.translation.iter())
.chain(node_pose.rotation.iter())
.chain(mount_pose.iter().flat_map(|pose| pose.translation.iter()))
.chain(mount_pose.iter().flat_map(|pose| pose.rotation.iter()))
.all(|value| value.is_finite())
{
return None;
}
let pose_matrix = |pose: VulkanNodePose| {
let [x, y, z] = [
rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation),
rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation),
rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation),
];
[
x[0],
y[0],
z[0],
pose.translation[0],
x[1],
y[1],
z[1],
pose.translation[1],
x[2],
y[2],
z[2],
pose.translation[2],
0.0,
0.0,
0.0,
1.0,
]
};
let scale = [
owner_scale[0],
0.0,
0.0,
0.0,
0.0,
owner_scale[1],
0.0,
0.0,
0.0,
0.0,
owner_scale[2],
0.0,
0.0,
0.0,
0.0,
1.0,
];
let mut world = multiply_row_major(root_transform.try_row_major()?, scale);
if let Some(mount) = mount_pose {
world = multiply_row_major(world, pose_matrix(mount));
}
world = multiply_row_major(world, pose_matrix(node_pose));
world.iter().all(|value| value.is_finite()).then_some(world)
}
#[derive(Clone, Debug)]
struct DynamicShadowCaster {
caster_index: usize,
object_index: usize,
root_transform: LegacyIron3dEulerTransform,
owner_scale: [f32; 3],
initial_sphere: ShadowSphere,
component_draw_range_indices: Vec<usize>,
component_spheres: Vec<DynamicShadowSphereSource>,
}
#[derive(Clone, Copy, Debug)]
struct DynamicShadowSphereSource {
component_index: usize,
center: [f32; 3],
radius: f32,
mounted: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct CapturedViewport {
/// Original D3D7 viewport rectangle `(left, top, right, bottom)`. The
/// preview window renders `extent` and should be compared with this crop.
rect: [i32; 4],
extent: [u32; 2],
}
fn validate_captured_drawable_extent(
captured: CapturedViewport,
actual: [u32; 2],
) -> Result<(), String> {
if actual == captured.extent {
return Ok(());
}
Err(format!(
"legacy camera viewport crop {:?} requires a {}x{} drawable, but the window provides {}x{}; refusing to compare a stretched render",
captured.rect, captured.extent[0], captured.extent[1], actual[0], actual[1]
))
}
#[derive(Clone, Copy, Debug, Default)]
struct StartupTimings {
mission_assets: Duration,
terrain_materials: Duration,
sky: Duration,
gpu_initialization: Duration,
}
#[derive(Clone, Copy, Debug, Default)]
struct ShadowFrameEvidence {
visible_casters: usize,
projected_indices: usize,
}
fn elapsed_ms(duration: Duration) -> f64 {
duration.as_secs_f64() * 1_000.0
}
fn unit_component_is_visible(index: usize, source_type: Option<u32>) -> bool {
index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO)
}
fn native_sun_object_kind_eligible(source_type: Option<u32>) -> bool {
native_shadow_kind(source_type).is_some()
}
const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT");
const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU");
const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT");
// shortcut: the preview does not load shade.cfg yet, so use CShade's native
// defaults until the settings loader is implemented.
const NATIVE_DEFAULT_ROB_SHADOWS_ON: bool = true;
const NATIVE_DEFAULT_BLD_SHADOWS_ON: bool = false;
/// Maps the resolved registry type to the native object-kind selector used by
/// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the
/// renderer receives the registry provenance from the prepared visual.
fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> {
match source_type {
Some(PROTOTYPE_TYPE_FORT) => Some(3),
Some(PROTOTYPE_TYPE_BTLU) => Some(4),
Some(PROTOTYPE_TYPE_STAT) => Some(10),
_ => None,
}
}
fn native_shadow_caster_enabled(native_kind: u32) -> bool {
if native_kind == 4 {
NATIVE_DEFAULT_ROB_SHADOWS_ON
} else {
NATIVE_DEFAULT_BLD_SHADOWS_ON
}
}
fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose {
let rotated = rotate_unit_vector(child.translation, parent.rotation);
VulkanNodePose {
translation: [
parent.translation[0] + rotated[0],
parent.translation[1] + rotated[1],
parent.translation[2] + rotated[2],
],
rotation: multiply_unit_quaternions(parent.rotation, child.rotation),
}
}
fn control_type4_local_matrix(
points: [&PreparedControlPoint; 3],
owner_scale: [f32; 3],
) -> Option<[[f32; 4]; 4]> {
if !owner_scale.iter().all(|value| value.is_finite()) {
return None;
}
let positions = points.map(|point| multiply3(point.position, owner_scale));
let directions = points.map(|point| multiply3(point.direction, owner_scale));
if !positions
.iter()
.flatten()
.chain(directions.iter().flatten())
.all(|value| value.is_finite())
{
return None;
}
let reciprocal_three = f32::from_bits(0x3EAA_AAAB);
let translation = std::array::from_fn(|axis| {
(positions[0][axis] + positions[1][axis] + positions[2][axis]) * reciprocal_three
});
if !translation.iter().all(|value| value.is_finite()) {
return None;
}
let [d0, d1, d2] = directions;
if d0 == d1 && d1 == d2 {
let magnitude = finite_length3(d0)?;
if magnitude <= 0.0 {
return None;
}
let basis = control_3ef0_basis(scale3(d0, magnitude.recip()), translation)?;
let scaled = std::array::from_fn(|row| {
let mut result = basis[row];
for column in 0..3 {
result[column] *= magnitude;
}
result
});
return scaled
.iter()
.flatten()
.all(|value| value.is_finite())
.then_some(scaled);
}
let (s, a) = if d0 == d2 || d1 == d2 {
(d0, d1)
} else if d0 == d1 {
(d0, d2)
} else {
let matrix = control_matrix_from_columns(d0, d1, d2, translation);
return matrix
.iter()
.flatten()
.all(|value| value.is_finite())
.then_some(matrix);
};
// In a repeated-direction row d0 is always the native S vector; the
// other vector selected above is A for helper 0x4070.
let cross = cross3(a, s);
let cross_length = finite_length3(cross)?;
if cross_length <= 0.0 {
return control_3ef0_basis(s, translation);
}
let u = scale3(cross, cross_length.recip());
let v = cross3(s, u);
let matrix = control_matrix_from_columns(s, u, v, translation);
matrix
.iter()
.flatten()
.all(|value| value.is_finite())
.then_some(matrix)
}
fn control_3ef0_basis(axis: [f32; 3], translation: [f32; 3]) -> Option<[[f32; 4]; 4]> {
if !axis
.iter()
.chain(translation.iter())
.all(|value| value.is_finite())
{
return None;
}
let mut u = if axis[0] == 0.0 {
[1.0, 0.0, 0.0]
} else if axis[1] == 0.0 {
[0.0, 1.0, 0.0]
} else {
[-axis[1], axis[0], 0.0]
};
if axis[2] != 0.0 {
let u_length = finite_length3(u)?;
if u_length <= 0.0 {
return None;
}
u = scale3(u, u_length.recip());
}
let v = cross3(axis, u);
let matrix = control_matrix_from_columns(axis, u, v, translation);
matrix
.iter()
.flatten()
.all(|value| value.is_finite())
.then_some(matrix)
}
fn control_matrix_from_columns(
first: [f32; 3],
second: [f32; 3],
third: [f32; 3],
translation: [f32; 3],
) -> [[f32; 4]; 4] {
[
[first[0], second[0], third[0], translation[0]],
[first[1], second[1], third[1], translation[1]],
[first[2], second[2], third[2], translation[2]],
[0.0, 0.0, 0.0, 1.0],
]
}
fn finite_length3(value: [f32; 3]) -> Option<f32> {
let length = dot3(value, value).sqrt();
length.is_finite().then_some(length)
}
fn control_type4_effect_local_matrix(
points: [&PreparedControlPoint; 3],
effect_scale: [f32; 3],
) -> Option<[[f32; 4]; 4]> {
if !effect_scale.iter().all(|value| value.is_finite()) {
return None;
}
let mut local = control_type4_local_matrix(points, [1.0; 3])?;
for (column, scale) in effect_scale.into_iter().enumerate() {
for row in &mut local[..3] {
row[column] *= scale;
}
}
local
.iter()
.flatten()
.all(|value| value.is_finite())
.then_some(local)
}
fn control_effect_owner_node_pose(
hierarchy: Option<&NodePoseBuffer>,
component_is_mounted: bool,
owner_node_index: usize,
) -> Option<VulkanNodePose> {
match hierarchy {
Some(hierarchy) if component_is_mounted => {
node38_pose_relative_to_root_from_hierarchy(hierarchy, owner_node_index)
}
Some(hierarchy) => node38_pose_from_hierarchy(hierarchy, owner_node_index),
None if owner_node_index == 0 => Some(VulkanNodePose {
translation: [0.0; 3],
rotation: [0.0, 0.0, 0.0, 1.0],
}),
None => None,
}
}
fn node_pose_row_major(pose: VulkanNodePose) -> [[f32; 4]; 4] {
let basis = [
rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation),
rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation),
rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation),
];
control_matrix_from_columns(basis[0], basis[1], basis[2], pose.translation)
}
fn component_mount_matrix(mount_pose: VulkanNodePose) -> [[f32; 4]; 4] {
node_pose_row_major(mount_pose)
}
fn component_scale_matrix(scale: [f32; 3]) -> [[f32; 4]; 4] {
[
[scale[0], 0.0, 0.0, 0.0],
[0.0, scale[1], 0.0, 0.0],
[0.0, 0.0, scale[2], 0.0],
[0.0, 0.0, 0.0, 1.0],
]
}
fn matrix_to_row_major(matrix: [[f32; 4]; 4]) -> [f32; 16] {
std::array::from_fn(|index| matrix[index / 4][index % 4])
}
fn control_type4_world_from_effect(
local_from_effect: [[f32; 4]; 4],
owner_scale: [f32; 3],
root_transform: LegacyIron3dEulerTransform,
mount_pose: Option<VulkanNodePose>,
owner_node_pose: VulkanNodePose,
) -> Option<[[f32; 4]; 4]> {
if !owner_scale
.iter()
.chain(local_from_effect.iter().flatten())
.chain(owner_node_pose.translation.iter())
.chain(owner_node_pose.rotation.iter())
.all(|value| value.is_finite())
{
return None;
}
let root = root_transform.try_row_major()?;
let mut world_flat = multiply_row_major(
root,
matrix_to_row_major(component_scale_matrix(owner_scale)),
);
if let Some(mount) = mount_pose {
world_flat = multiply_row_major(
world_flat,
matrix_to_row_major(component_mount_matrix(mount)),
);
}
world_flat = multiply_row_major(
world_flat,
matrix_to_row_major(node_pose_row_major(owner_node_pose)),
);
world_flat = multiply_row_major(world_flat, matrix_to_row_major(local_from_effect));
let world =
std::array::from_fn(|row| std::array::from_fn(|column| world_flat[row * 4 + column]));
world
.iter()
.flatten()
.all(|value| value.is_finite())
.then_some(world)
}
fn control_type4_world_from_effect_with_random_shift(
mut local_from_effect: [[f32; 4]; 4],
random_shift: [f32; 3],
owner_scale: [f32; 3],
root_transform: LegacyIron3dEulerTransform,
mount_pose: Option<VulkanNodePose>,
owner_node_pose: VulkanNodePose,
) -> Option<[[f32; 4]; 4]> {
for (axis, value) in random_shift.into_iter().enumerate() {
local_from_effect[axis][3] += value;
}
control_type4_world_from_effect(
local_from_effect,
owner_scale,
root_transform,
mount_pose,
owner_node_pose,
)
}
#[cfg(test)]
fn ambient_effect_world_from_component_pose(
slot: &AmbientEffectGpuSlot,
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
) -> Option<[[f32; 4]; 4]> {
ambient_effect_world_from_component_pose_with_random_shift(
slot,
component_pose_buffers,
[0.0; 3],
)
}
fn ambient_effect_world_from_component_pose_with_random_shift(
slot: &AmbientEffectGpuSlot,
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
random_shift: [f32; 3],
) -> Option<[[f32; 4]; 4]> {
let hierarchy = component_pose_buffers
.get(slot.owner_object_index)
.and_then(|components| components.get(slot.owner_component_index))
.and_then(Option::as_ref);
let owner_node_pose = control_effect_owner_node_pose(
hierarchy,
slot.owner_component_is_mounted,
slot.owner_node_index,
)?;
control_type4_world_from_effect_with_random_shift(
slot.local_from_effect,
random_shift,
slot.owner_scale,
slot.root_transform,
slot.mount_pose,
owner_node_pose,
)
}
fn control_effect_world_from_component_pose_with_random_shift(
slot: &ControlEffectSlot,
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
random_shift: [f32; 3],
) -> Option<[[f32; 4]; 4]> {
let hierarchy = component_pose_buffers
.get(slot.owner_object_index)
.and_then(|components| components.get(slot.owner_component_index))
.and_then(Option::as_ref);
let owner_node_pose = control_effect_owner_node_pose(
hierarchy,
slot.owner_component_is_mounted,
slot.owner_node_index,
)?;
control_type4_world_from_effect_with_random_shift(
slot.local_from_effect,
random_shift,
slot.owner_scale,
slot.root_transform,
slot.mount_pose,
owner_node_pose,
)
}
fn update_control_point_lights(
slots: &mut [ControlEffectSlot],
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
component_mount_poses: &[Vec<Option<VulkanNodePose>>],
elapsed_seconds: f32,
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
emit_diagnostic: bool,
) -> Vec<(VulkanPointLight, u32)> {
let mut lights = Vec::new();
for slot in slots {
if slot.owner_component_is_mounted {
slot.mount_pose = component_mount_poses
.get(slot.owner_object_index)
.and_then(|components| components.get(slot.owner_component_index))
.copied()
.flatten();
}
let random_sample = random_samples
.get(&slot.random_instance_key)
.copied()
.unwrap_or_default();
let Some(world_from_effect) = control_effect_world_from_component_pose_with_random_shift(
slot,
component_pose_buffers,
random_sample.accumulated_shift,
) else {
continue;
};
slot.world_from_effect = world_from_effect;
let Some(phase) = ambient_effect_phase(
slot.time_mode,
slot.header_flags,
elapsed_seconds,
slot.duration_seconds,
)
.map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
continue;
};
let matrix = slot.world_from_effect;
let origin = [matrix[0][3], matrix[1][3], matrix[2][3]];
let basis = [
[matrix[0][0], matrix[1][0], matrix[2][0]],
[matrix[0][1], matrix[1][1], matrix[2][1]],
[matrix[0][2], matrix[1][2], matrix[2][2]],
];
let Some(frame) = evaluate_op1_point_light_with_basis(&slot.command, phase, origin, basis)
else {
continue;
};
if !frame.active || frame.light_selector != 1 {
continue;
}
if emit_diagnostic {
eprintln!(
"control_point_light_detail object={} component={} table={} row={} command={} header_flags=0x{:08X} effect_type={} native_flags=0x{:08X} selector={} command_time={} phase={:.4} local_position_start={:?} local_position_end={:?} world_origin={:?} output_position={:?} rgb={:?} alpha={:.3} range={} attenuation={:?} effect_factor={}",
slot.owner_object_index,
slot.owner_component_index,
slot.random_instance_key.table_index,
slot.random_instance_key.row_index,
slot.command_index,
slot.header_flags,
frame.effect_type,
frame.native_flags,
frame.light_selector,
frame.command_time,
phase,
fx_command_vec3(&slot.command, 12),
fx_command_vec3(&slot.command, 24),
[matrix[0][3], matrix[1][3], matrix[2][3]],
frame.position,
frame.color,
frame.color[3],
frame.range,
frame.attenuation,
frame.effect_factor,
);
}
lights.push((
VulkanPointLight {
position: frame.position,
rgb: [frame.color[0], frame.color[1], frame.color[2]],
range: frame.range,
coefficients: frame.attenuation,
active: true,
},
frame.native_flags,
));
}
lights
}
fn fx_command_vec3(command: &FxCommand, offset: usize) -> Option<[f32; 3]> {
let mut result = [0.0; 3];
for (axis, value) in result.iter_mut().enumerate() {
let start = offset.checked_add(axis.checked_mul(4)?)?;
let bytes = command.raw_body.get(start..start.checked_add(4)?)?;
*value = f32::from_le_bytes(bytes.try_into().ok()?);
}
Some(result)
}
fn report_control_point_lights_once(
reported: &mut bool,
control_clock_ms: u32,
target_clock_ms: Option<u32>,
environment_count: usize,
raw_control_lights: &[(VulkanPointLight, u32)],
ordinary_control_lights: &[VulkanPointLight],
) {
if *reported || std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_none() {
return;
}
if target_clock_ms.is_some_and(|target| control_clock_ms < target) {
return;
}
eprintln!(
"control_point_lights clock_ms={control_clock_ms} environment_count={environment_count} control_raw_count={} control_ordinary_count={} raw_flags={:?}",
raw_control_lights.len(),
ordinary_control_lights.len(),
raw_control_lights.iter().map(|(_, flags)| *flags).collect::<Vec<_>>(),
);
*reported = true;
}
fn ordinary_control_point_lights(
raw_control_lights: &[(VulkanPointLight, u32)],
) -> Vec<VulkanPointLight> {
raw_control_lights
.iter()
.filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0)
.map(|(light, _)| *light)
.collect()
}
fn initial_native_effect_phase(header_flags: u32, phase: f32) -> f32 {
if header_flags & 0x80 != 0 {
0.0
} else {
phase
}
}
fn native_command_phase(header_flags: u32, phase: f32, random_phase_jitter: f32) -> f32 {
let initial_phase = initial_native_effect_phase(header_flags, phase);
if initial_phase == -1.0 {
-1.0
} else {
(initial_phase
+ if header_flags & 1 != 0 {
random_phase_jitter
} else {
0.0
})
.clamp(0.0, 1.0)
}
}
fn control_row_points<'a>(
point_list: &'a PreparedControlPointList,
row: &PreparedControlEffectRow,
) -> Option<[&'a PreparedControlPoint; 3]> {
let mut result = Vec::with_capacity(3);
for offset in row.point_offsets {
result.push(point_list.points.get(usize::try_from(offset).ok()?)?);
}
result.try_into().ok()
}
fn append_initial_control_effect_slots(
root: &Path,
mission_root: &MissionObjectDraft,
object_index: usize,
component_index: usize,
control: &PreparedComponentControl,
component_pose_buffer: Option<&NodePoseBuffer>,
mount_pose: Option<VulkanNodePose>,
mesh: &mut VulkanStaticMesh,
materials: &mut Vec<VulkanStaticMaterial>,
material_animations: &mut Vec<MaterialAnimationBinding>,
effect_documents: &mut HashMap<String, FxDocument>,
effect_materials: &mut HashMap<String, PreparedEffectMaterial>,
slots: &mut Vec<AmbientEffectGpuSlot>,
point_light_slots: &mut Vec<ControlEffectSlot>,
sound_slots: &mut Vec<ControlSoundSlot>,
unsupported_sounds: &mut usize,
) -> Result<(), String> {
let Some(point_list) = control.point_list.as_ref() else {
return Ok(());
};
for row in
selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section)
{
let group_index = row.table_index;
let archive = fixed_effect_field(&row.resource.archive);
let name = fixed_effect_field(&row.resource.name);
let points = control_row_points(point_list, row).ok_or_else(|| {
format!(
"Control group {group_index} row {} references a missing CPT point {:?}",
row.row_index, row.point_offsets
)
})?;
let key = format!("{archive}/{name}");
if !effect_documents.contains_key(&key) {
let bytes = load_resource_entry_bytes_from_root(root, &archive, &name)
.map_err(|error| format!("load Control FX {key}: {error}"))?;
let Ok(document) = decode_fxid(bytes) else {
continue;
};
effect_documents.insert(key.clone(), document);
}
let document = effect_documents
.get(&key)
.ok_or_else(|| format!("Control FX {key} was not cached"))?;
let header = document.header();
let sound_commands = document
.commands()
.iter()
.filter(|command| command.opcode == FxOpcode::Op2)
.count();
let duration_seconds = header.duration_seconds;
if !duration_seconds.is_finite()
|| duration_seconds < 0.0
|| !matches!(header.time_mode, 0 | 1 | 2)
|| (header.time_mode != 0 && duration_seconds <= 0.0)
|| !header.phase_jitter.is_finite()
|| !header.random_shift.iter().all(|value| value.is_finite())
|| !header.scale.iter().all(|value| value.is_finite())
{
// Control can reference action effects whose header timing and
// scale conventions are outside this ambient subset. Leave them
// unexpanded until their lifetime and transform contract are
// understood rather than failing the whole mission preview.
*unsupported_sounds += sound_commands;
continue;
}
let owner_node_index =
usize::try_from(points[0].raw_signed_field_04)
.ok()
.filter(|owner_node| {
component_pose_buffer
.is_some_and(|hierarchy| *owner_node < hierarchy.poses.len())
|| (*owner_node == 0 && component_pose_buffer.is_none())
});
let Some(owner_node_index) = owner_node_index else {
*unsupported_sounds += sound_commands;
continue;
};
let owner_node_pose = control_effect_owner_node_pose(
component_pose_buffer,
mount_pose.is_some(),
owner_node_index,
);
let Some(owner_node_pose) = owner_node_pose else {
*unsupported_sounds += sound_commands;
continue;
};
let root_transform = LegacyIron3dEulerTransform {
translation: mission_root.position,
orientation_radians: mission_root.orientation_raw,
};
let local_from_effect = control_type4_effect_local_matrix(points, header.scale)
.ok_or_else(|| format!("Control FX {key} has an invalid type-4 transform"))?;
let world_from_effect = control_type4_world_from_effect(
local_from_effect,
mission_root.scale,
root_transform,
mount_pose,
owner_node_pose,
)
.ok_or_else(|| format!("Control FX {key} has an invalid owner transform"))?;
let control_source = format!(
"{}/{}",
control.source.archive.as_str(),
String::from_utf8_lossy(&control.source.name.0)
);
let random_instance_key = ControlEffectInstanceKey {
object_index,
component_index,
table_index: row.table_index,
row_index: row.row_index,
};
let random_seed = control_effect_instance_seed(
object_index,
component_index,
row.table_index,
row.row_index,
&control_source,
);
for (command_index, command) in document.commands().iter().enumerate() {
if ambient_effect_op1_supported(command) || command.opcode == FxOpcode::Op2 {
let slot = ControlEffectSlot {
time_mode: header.time_mode,
header_flags: header.flags,
random_instance_key,
random_seed,
phase_jitter: header.phase_jitter,
random_shift: header.random_shift,
duration_seconds,
owner_object_index: object_index,
owner_component_index: component_index,
owner_node_index,
owner_component_is_mounted: mount_pose.is_some(),
root_transform,
owner_scale: mission_root.scale,
mount_pose,
local_from_effect,
world_from_effect,
command_index,
command: command.clone(),
};
if command.opcode == FxOpcode::Op2 {
if let Some(sound) = Op2SoundCommand::from_command(command) {
sound_slots.push(ControlSoundSlot {
effect: slot,
command: sound,
state: Op2SoundState::default(),
});
} else {
*unsupported_sounds += 1;
eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)");
}
} else {
point_light_slots.push(slot);
}
continue;
}
let Some(kind) = ambient_effect_command_kind(command) else {
continue;
};
if !ambient_effect_render_selector_allows_header(
header.flags,
CONTROL_COMPONENT_RENDER_SELECTOR,
) {
continue;
}
let Some(reference) = command.resource_refs.first() else {
continue;
};
let material_archive = String::from_utf8_lossy(reference.archive_name()).into_owned();
let material_name = String::from_utf8_lossy(reference.resource_name()).into_owned();
if material_archive.is_empty() || material_name.is_empty() {
continue;
}
let cache_key = format!("{material_archive}/{material_name}");
if !effect_materials.contains_key(&cache_key) {
let _prepared = prepare_effect_material(
root,
&material_archive,
&material_name,
materials,
effect_materials,
)?;
}
let prepared = effect_materials
.get(&cache_key)
.ok_or_else(|| format!("FX MAT0 {cache_key} was not cached"))?;
if !ambient_effect_tint_supported(
&prepared.phase_native_flag_40,
&prepared.phase_directional_rgb,
&prepared.phase_additive_rgb,
) {
continue;
}
let first_material = *prepared
.phase_material_indices
.first()
.ok_or_else(|| format!("FX MAT0 {cache_key} has no phase variants"))?;
let op9_mesh_template =
if kind == AmbientEffectKind::GenerBall && command.opcode == FxOpcode::Op9 {
let Some(template) = evaluate_f_gener_ball_mesh(
command,
command_index,
&world_from_effect,
0.0,
0.0,
[0.0; 3],
AMBIENT_EFFECT_NATIVE_CATEGORY,
) else {
continue;
};
Some(template)
} else {
None
};
let op9_primitive_layout = op9_mesh_template
.as_ref()
.map(ambient_effect_mesh_primitive_layout)
.transpose()?;
let placeholder: [VulkanStaticVertex; 4] = std::array::from_fn(|index| {
environment_vertex_with_alpha(
[0.0; 3],
[1.0; 3],
0.0,
[[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]][index],
)
});
let pipeline_state = ambient_effect_pipeline_state(prepared.initial_pipeline_category)
.ok_or_else(|| {
format!(
"FX MAT0 {cache_key} has unsupported pipeline category {}",
prepared.initial_pipeline_category
)
})?;
let pool_capacity = match kind {
AmbientEffectKind::SmokePool => ambient_effect_pool_capacity(command)
.ok_or_else(|| format!("FX Op8 {cache_key} has an invalid pool size"))?,
AmbientEffectKind::Op7ParticlePool => ambient_effect_op7_range_capacity(command)
.ok_or_else(|| format!("FX Op7 {cache_key} has an invalid particle pool"))?,
_ => 1,
};
let range_capacity = op9_primitive_layout
.as_ref()
.map_or(pool_capacity, Vec::len);
let mut particle_ranges = Vec::with_capacity(range_capacity);
let mut particle_material_animation_indices = Vec::with_capacity(range_capacity);
for _ in 0..pool_capacity {
if let (Some(template), Some(primitives)) =
(&op9_mesh_template, &op9_primitive_layout)
{
for primitive in primitives {
let vertices = ambient_effect_mesh_primitive_vertices(template, *primitive);
let indices = ambient_effect_mesh_primitive_indices(template, *primitive)?;
let range = append_environment_range(
mesh,
first_material,
vertices,
indices,
pipeline_state,
)?;
let material_animation_index = append_effect_material_animation(
prepared,
range.range_index,
material_animations,
);
particle_ranges.push(range);
particle_material_animation_indices.push(material_animation_index);
}
} else {
let range = append_environment_range(
mesh,
first_material,
placeholder.to_vec(),
vec![0, 1, 2, 2, 3, 0],
pipeline_state,
)?;
let material_animation_index = append_effect_material_animation(
prepared,
range.range_index,
material_animations,
);
particle_ranges.push(range);
particle_material_animation_indices.push(material_animation_index);
}
}
let range = particle_ranges.remove(0);
let material_animation_index = particle_material_animation_indices.remove(0);
let smoke_emitter_state = (kind == AmbientEffectKind::SmokePool).then(|| {
let seed = ambient_effect_seed(
object_index,
component_index,
row.table_index,
row.row_index,
command_index,
&control_source,
);
SmokeEmitterState::new(seed, seed, seed)
});
let op7_emitter_state = (kind == AmbientEffectKind::Op7ParticlePool).then(|| {
let seed = ambient_effect_seed(
object_index,
component_index,
row.table_index,
row.row_index,
command_index,
&control_source,
);
Op7EmitterState::new(seed, seed)
});
slots.push(AmbientEffectGpuSlot {
kind,
time_mode: header.time_mode,
header_flags: header.flags,
random_instance_key,
random_seed,
phase_jitter: header.phase_jitter,
random_shift: header.random_shift,
owner_object_index: object_index,
owner_component_index: component_index,
owner_node_index,
owner_component_is_mounted: mount_pose.is_some(),
root_transform,
owner_scale: mission_root.scale,
mount_pose,
local_from_effect,
control_source: control_source.clone(),
control_table_index: row.table_index,
control_row_index: row.row_index,
control_point_indices: row.point_offsets,
control_point_owners: points.map(|point| point.raw_signed_field_04),
command: command.clone(),
command_index,
duration_seconds,
world_from_effect,
range,
additional_particle_ranges: particle_ranges,
material_animation_index,
additional_particle_material_animation_indices: particle_material_animation_indices,
smoke_emitter_state,
op7_emitter_state,
});
}
}
Ok(())
}
fn selected_initial_control_effect_rows<'a>(
group_index: Option<usize>,
section: &'a PreparedControlEffectSection,
) -> Vec<&'a PreparedControlEffectRow> {
let (Some(group_index), PreparedControlEffectSection::Parsed(rows)) = (group_index, section)
else {
return Vec::new();
};
rows.iter()
.filter(|row| {
row.table_index == group_index && ambient_effect_row_unconditionally_active(row)
})
.collect()
}
fn ambient_effect_row_unconditionally_active(row: &PreparedControlEffectRow) -> bool {
// Control22C0 returns true when none of the predicate bits are set. The
// two row-chain bits have separate dispatch semantics and stay excluded.
row.raw_condition[0] & 0xF000_0000 == 0
}
fn ambient_effect_command_kind(command: &FxCommand) -> Option<AmbientEffectKind> {
let body = command.raw_body.as_ref();
let mode = body
.get(..4)
.map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX body mode")));
let body_c4 = body
.get(0xc4..0xc8)
.map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX C4 field")));
match (command.opcode, mode, body_c4) {
(FxOpcode::Op3, Some(0), _) if command.raw_body.len() >= 0xC4 => {
Some(AmbientEffectKind::Op3Type0)
}
(FxOpcode::Op3, Some(1), _) if command.raw_body.len() >= 0xC4 => {
Some(AmbientEffectKind::Op3Type1)
}
(FxOpcode::Op3, Some(2), _) => Some(AmbientEffectKind::Op3Type2),
(FxOpcode::Op9, Some(2), Some(0..=2)) => Some(AmbientEffectKind::GenerBall),
(FxOpcode::Op7, _, _) if ambient_effect_op7_range_capacity(command).is_some() => {
Some(AmbientEffectKind::Op7ParticlePool)
}
(FxOpcode::Op8, _, _) if ambient_effect_pool_capacity(command).is_some() => {
Some(AmbientEffectKind::SmokePool)
}
(FxOpcode::Op4, Some(0), Some(denominator_bits))
if f32::from_bits(denominator_bits).is_finite()
&& f32::from_bits(denominator_bits) > 0.0 =>
{
Some(AmbientEffectKind::GenerBall)
}
_ => None,
}
}
const CONTROL_COMPONENT_RENDER_SELECTOR: u32 = 1;
fn ambient_effect_render_selector_allows_header(header_flags: u32, render_selector: u32) -> bool {
// Effect.dll 0x7D10 suppresses header flag 0x800 only for selector zero.
// The native AniMesh component path was observed calling the manager with
// selector one while its 0xD00 f_smalllight_y entries were present.
render_selector != 0 || header_flags & 0x800 == 0
}
fn ambient_effect_op1_supported(command: &FxCommand) -> bool {
if command.opcode != FxOpcode::Op1 {
return false;
}
let Some(effect_type) = command
.raw_body
.get(..4)
.and_then(|bytes| bytes.try_into().ok())
.map(u32::from_le_bytes)
else {
return false;
};
command.raw_body.len() >= 132 && matches!(effect_type, 1 | 2 | 5 | 6 | 7)
}
fn ambient_effect_pool_capacity(command: &FxCommand) -> Option<usize> {
if command.opcode != FxOpcode::Op8 {
return None;
}
let pool_count = usize::try_from(u32::from_le_bytes(
command.raw_body.get(0x20..0x24)?.try_into().ok()?,
))
.ok()?;
(1..=4096).contains(&pool_count).then_some(pool_count)
}
fn ambient_effect_op7_range_capacity(command: &FxCommand) -> Option<usize> {
if command.opcode != FxOpcode::Op7 || command.raw_body.len() < 0xCC {
return None;
}
let read_u32 = |offset: usize| -> Option<u32> {
Some(u32::from_le_bytes(
command.raw_body.get(offset..offset + 4)?.try_into().ok()?,
))
};
let read_f32 = |offset| read_u32(offset).map(f32::from_bits);
// The evaluator uses the traced default world scalar of 10 for this
// Control path; the command's acceleration is supported when finite.
let material_phase_factor = read_f32(0x1C)?;
if !matches!(read_u32(0x00)?, 0 | 1)
|| !material_phase_factor.is_finite()
|| material_phase_factor == 0.0
|| !read_f32(0x88)?.is_finite()
{
return None;
}
let pool_count = usize::try_from(read_u32(0x20)?).ok()?;
let copies_per_group = usize::try_from(read_u32(0x24)?).ok()?;
let capacity = pool_count.checked_mul(copies_per_group)?;
(1..=4096).contains(&capacity).then_some(capacity)
}
fn ambient_effect_seed(
object_index: usize,
component_index: usize,
table_index: usize,
row_index: usize,
command_index: usize,
source: &str,
) -> u32 {
let mut hash = 0x811C_9DC5_u32;
for byte in source.bytes().chain(
[
object_index,
component_index,
table_index,
row_index,
command_index,
]
.into_iter()
.flat_map(|value| (value as u64).to_le_bytes()),
) {
hash = (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193);
}
if hash == 0 {
1
} else {
hash
}
}
fn control_effect_instance_seed(
object_index: usize,
component_index: usize,
table_index: usize,
row_index: usize,
source: &str,
) -> u32 {
ambient_effect_seed(
object_index,
component_index,
table_index,
row_index,
0,
source,
)
}
fn advance_control_effect_random_states(
states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
ambient_slots: &[AmbientEffectGpuSlot],
point_light_slots: &[ControlEffectSlot],
sound_slots: &[ControlSoundSlot],
sample_id: SceneSampleId,
control_clock_ms: u32,
elapsed_seconds: f32,
) -> HashMap<ControlEffectInstanceKey, ControlEffectRandomSample> {
let mut specifications = HashMap::new();
for slot in ambient_slots {
let initial_phase = ambient_effect_phase(
slot.time_mode,
slot.header_flags,
elapsed_seconds,
slot.duration_seconds,
)
.map(|phase| initial_native_effect_phase(slot.header_flags, phase))
.unwrap_or(-1.0);
specifications.entry(slot.random_instance_key).or_insert((
slot.random_seed,
slot.header_flags,
slot.phase_jitter,
slot.random_shift,
initial_phase,
));
}
for slot in point_light_slots
.iter()
.chain(sound_slots.iter().map(|slot| &slot.effect))
{
let initial_phase = ambient_effect_phase(
slot.time_mode,
slot.header_flags,
elapsed_seconds,
slot.duration_seconds,
)
.map(|phase| initial_native_effect_phase(slot.header_flags, phase))
.unwrap_or(-1.0);
specifications.entry(slot.random_instance_key).or_insert((
slot.random_seed,
slot.header_flags,
slot.phase_jitter,
slot.random_shift,
initial_phase,
));
}
specifications
.into_iter()
.map(
|(key, (seed, flags, phase_jitter, random_shift, initial_phase))| {
let state = states.entry(key).or_insert_with(|| {
ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift)
});
(
key,
state.advance(sample_id, control_clock_ms, initial_phase),
)
},
)
.collect()
}
fn ambient_effect_ranges(slot: &AmbientEffectGpuSlot) -> Vec<(&EnvironmentGpuRange, usize)> {
std::iter::once((&slot.range, slot.material_animation_index))
.chain(
slot.additional_particle_ranges
.iter()
.zip(&slot.additional_particle_material_animation_indices)
.map(|(range, &binding)| (range, binding)),
)
.collect()
}
fn ambient_effect_quad_center(vertices: &[[f32; 3]; 4]) -> [f32; 3] {
std::array::from_fn(|axis| vertices.iter().map(|vertex| vertex[axis]).sum::<f32>() / 4.0)
}
fn ambient_effect_quad_size(vertices: &[[f32; 3]; 4]) -> [f32; 2] {
let edge_length = |a: [f32; 3], b: [f32; 3]| {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
};
[
edge_length(vertices[0], vertices[1]),
edge_length(vertices[1], vertices[2]),
]
}
fn ambient_effect_projected_bounds(
vertices: &[[f32; 3]; 4],
clip_from_world: [f32; 16],
viewport: [u32; 2],
) -> Option<[f32; 4]> {
let mut points = [[0.0; 2]; 4];
for (index, vertex) in vertices.iter().enumerate() {
let clip = [
vertex[0] * clip_from_world[0]
+ vertex[1] * clip_from_world[4]
+ vertex[2] * clip_from_world[8]
+ clip_from_world[12],
vertex[0] * clip_from_world[1]
+ vertex[1] * clip_from_world[5]
+ vertex[2] * clip_from_world[9]
+ clip_from_world[13],
vertex[0] * clip_from_world[2]
+ vertex[1] * clip_from_world[6]
+ vertex[2] * clip_from_world[10]
+ clip_from_world[14],
vertex[0] * clip_from_world[3]
+ vertex[1] * clip_from_world[7]
+ vertex[2] * clip_from_world[11]
+ clip_from_world[15],
];
if !clip.iter().all(|value| value.is_finite()) || clip[3] <= f32::EPSILON {
return None;
}
points[index] = [
(clip[0] / clip[3] + 1.0) * 0.5 * viewport[0] as f32,
(clip[1] / clip[3] + 1.0) * 0.5 * viewport[1] as f32,
];
}
Some([
points
.iter()
.map(|point| point[0])
.fold(f32::INFINITY, f32::min),
points
.iter()
.map(|point| point[1])
.fold(f32::INFINITY, f32::min),
points
.iter()
.map(|point| point[0])
.fold(f32::NEG_INFINITY, f32::max),
points
.iter()
.map(|point| point[1])
.fold(f32::NEG_INFINITY, f32::max),
])
}
fn ambient_effect_range_for(
slot: &AmbientEffectGpuSlot,
range_index: usize,
) -> Option<&EnvironmentGpuRange> {
std::iter::once(&slot.range)
.chain(slot.additional_particle_ranges.iter())
.find(|range| range.range_index == range_index)
}
fn ambient_effect_pipeline_state(category: u8) -> Option<LegacyPipelineState> {
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly)
.with_material_category(category)
}
fn ambient_effect_tint_supported(
phase_native_flag_40: &[bool],
phase_directional_rgb: &[[f32; 3]],
phase_additive_rgb: &[[f32; 3]],
) -> bool {
!phase_native_flag_40.is_empty()
&& phase_native_flag_40.len() == phase_directional_rgb.len()
&& phase_native_flag_40.len() == phase_additive_rgb.len()
&& phase_native_flag_40.iter().all(|flag| !flag)
&& phase_directional_rgb
.iter()
.zip(phase_additive_rgb)
.all(|(directional, additive)| {
directional
.iter()
.chain(additive)
.all(|channel| channel.is_finite() && (0.0..=1.0).contains(channel))
&& (0..3).all(|channel| additive[channel] > 0.0 || directional[channel] == 0.0)
})
}
fn fixed_effect_field(field: &[u8; 32]) -> String {
let length = field
.iter()
.position(|byte| *byte == 0)
.unwrap_or(field.len());
String::from_utf8_lossy(&field[..length]).trim().to_owned()
}
fn update_ambient_effect_geometry(
slots: &mut [AmbientEffectGpuSlot],
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
mesh: &mut VulkanStaticMesh,
material_animations: &mut [MaterialAnimationBinding],
elapsed_seconds: f32,
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
camera_world: [f32; 3],
) -> Result<Vec<AmbientEffectGeometryUpdate>, String> {
let mut updates = Vec::with_capacity(slots.len());
for slot in slots {
let random_sample = random_samples
.get(&slot.random_instance_key)
.copied()
.unwrap_or_default();
let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift(
slot,
component_pose_buffers,
random_sample.accumulated_shift,
) else {
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
continue;
};
slot.world_from_effect = world_from_effect;
let Some(phase) = ambient_effect_phase(
slot.time_mode,
slot.header_flags,
elapsed_seconds,
slot.duration_seconds,
)
.map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
continue;
};
if slot.kind == AmbientEffectKind::GenerBall && slot.command.opcode == FxOpcode::Op9 {
let Some(frame) = evaluate_f_gener_ball_mesh(
&slot.command,
slot.command_index,
&slot.world_from_effect,
phase,
elapsed_seconds,
camera_world,
AMBIENT_EFFECT_NATIVE_CATEGORY,
) else {
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
continue;
};
let primitives = ambient_effect_mesh_primitive_layout(&frame)?;
let ranges = ambient_effect_ranges(slot);
if ranges.len() != primitives.len() {
return Err(format!(
"ambient Op9 draw range count changed ({} != {})",
ranges.len(),
primitives.len()
));
}
for (primitive, (range, material_animation_index)) in primitives.into_iter().zip(ranges)
{
let binding = material_animations
.get_mut(material_animation_index)
.ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
binding.normalized_phase_override = Some(frame.material_phase);
write_ambient_effect_mesh_primitive(mesh, range, &frame, primitive)?;
updates.push(AmbientEffectGeometryUpdate {
range_index: range.range_index,
index_count: if frame.active && frame.color[3] > 0.0 {
u32::try_from(primitive.index_count)
.map_err(|_| "ambient Op9 index count exceeds u32".to_string())?
} else {
0
},
command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
});
}
continue;
}
let frames = match (slot.kind, slot.command.opcode) {
(AmbientEffectKind::Op3Type0, FxOpcode::Op3) => evaluate_op3_type0_command(
&slot.command,
slot.command_index,
&slot.world_from_effect,
elapsed_seconds,
phase,
camera_world,
)
.into_iter()
.collect(),
(AmbientEffectKind::Op7ParticlePool, FxOpcode::Op7) => {
let Some(state) = slot.op7_emitter_state.as_mut() else {
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
continue;
};
let Some(frames) = evaluate_op7_emitter(
&slot.command,
slot.command_index,
&slot.world_from_effect,
camera_world,
phase,
elapsed_seconds,
Op7RenderContext {
detail_level: 0,
// At detail level zero, [0,1) cutoffs with zero bias
// are all visible. The native quality preset remains
// unresolved, but no particle is culled here.
visibility: Op7VisibilityProfile {
width: 1.0,
bias: 0.0,
},
// Terrain.dll initializes the native world's world
// scalar to 10.0. An engine integration can supply the
// live value after it exposes the world setting.
world_scalar: 10.0,
},
state,
) else {
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
continue;
};
fx_material_speed_diagnostic(slot, 0xAC, elapsed_seconds, phase, &frames);
frames
}
(AmbientEffectKind::SmokePool, FxOpcode::Op8) => {
let Some(state) = slot.smoke_emitter_state.as_mut() else {
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
continue;
};
let frames = evaluate_op8_emitter(
&slot.command,
slot.command_index,
&slot.world_from_effect,
camera_world,
elapsed_seconds,
phase,
0,
Op8DetailThresholdRange::new(1.0, 1.0)
.expect("ordered finite Op8 material threshold"),
state,
)
.unwrap_or_default();
smoke_fr_02_diagnostic(slot, elapsed_seconds, phase, &frames);
fx_material_speed_diagnostic(slot, 0xD4, elapsed_seconds, phase, &frames);
frames
}
(AmbientEffectKind::Op3Type1, FxOpcode::Op3) => evaluate_op3_type1_command(
&slot.command,
slot.command_index,
&slot.world_from_effect,
elapsed_seconds,
phase,
camera_world,
)
.into_iter()
.collect(),
(AmbientEffectKind::Op3Type2, FxOpcode::Op3) => evaluate_op3_type2_command(
&slot.command,
slot.command_index,
&slot.world_from_effect,
elapsed_seconds,
phase,
0,
)
.into_iter()
.collect(),
(AmbientEffectKind::GenerBall, FxOpcode::Op4) => evaluate_f_gener_ball(
&slot.command,
slot.command_index,
&slot.world_from_effect,
phase,
elapsed_seconds,
camera_world,
)
.into_iter()
.collect(),
_ => Vec::new(),
};
let ranges = ambient_effect_ranges(slot);
if frames.is_empty() {
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
continue;
}
let variable_pool = slot.kind == AmbientEffectKind::Op7ParticlePool;
let aligned_frames = align_ambient_effect_frames(frames, ranges.len(), variable_pool)?;
for ((range, material_animation_index), frame) in ranges.into_iter().zip(aligned_frames) {
if let Some(frame) = frame {
let binding = material_animations
.get_mut(material_animation_index)
.ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
binding.normalized_phase_override = Some(frame.material_phase);
write_ambient_effect_frame(mesh, range, &frame)?;
updates.push(AmbientEffectGeometryUpdate {
range_index: range.range_index,
index_count: if frame.active && frame.color[3] > 0.0 {
6
} else {
0
},
command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
});
} else {
deactivate_ambient_effect_range(
range,
material_animation_index,
material_animations,
&mut updates,
)?;
}
}
}
Ok(updates)
}
fn align_ambient_effect_frames(
frames: Vec<fparkan_fx::fx_visual::EffectQuadFrame>,
range_count: usize,
variable_pool: bool,
) -> Result<Vec<Option<fparkan_fx::fx_visual::EffectQuadFrame>>, String> {
if (variable_pool && frames.len() > range_count)
|| (!variable_pool && frames.len() != range_count)
{
return Err(format!(
"ambient FX frame pool has {} particles but {range_count} draw ranges",
frames.len()
));
}
let active_count = frames.len();
let mut aligned: Vec<_> = frames.into_iter().map(Some).collect();
aligned.extend(std::iter::repeat_with(|| None).take(range_count - active_count));
Ok(aligned)
}
fn deactivate_ambient_effect_slot(
slot: &AmbientEffectGpuSlot,
material_animations: &mut [MaterialAnimationBinding],
updates: &mut Vec<AmbientEffectGeometryUpdate>,
) -> Result<(), String> {
for (range, material_animation_index) in ambient_effect_ranges(slot) {
deactivate_ambient_effect_range(
range,
material_animation_index,
material_animations,
updates,
)?;
}
Ok(())
}
fn deactivate_ambient_effect_range(
range: &EnvironmentGpuRange,
material_animation_index: usize,
material_animations: &mut [MaterialAnimationBinding],
updates: &mut Vec<AmbientEffectGeometryUpdate>,
) -> Result<(), String> {
let binding = material_animations
.get_mut(material_animation_index)
.ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
binding.normalized_phase_override = Some(0.0);
updates.push(AmbientEffectGeometryUpdate {
range_index: range.range_index,
index_count: 0,
command_rgb: [0.0; 3],
});
Ok(())
}
fn write_ambient_effect_frame(
mesh: &mut VulkanStaticMesh,
range: &EnvironmentGpuRange,
frame: &fparkan_fx::fx_visual::EffectQuadFrame,
) -> Result<(), String> {
let end = range
.vertex_start
.checked_add(range.vertex_capacity)
.ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
let destination = mesh
.vertices
.get_mut(range.vertex_start..end)
.ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
if destination.len() != 4 {
return Err("ambient FX quad allocation changed after GPU preparation".to_string());
}
for (index, target) in destination.iter_mut().enumerate() {
*target = environment_vertex_with_alpha(
frame.vertices[index],
[frame.color[0], frame.color[1], frame.color[2]],
frame.color[3].clamp(0.0, 1.0),
frame.uv[index],
);
}
Ok(())
}
fn ambient_effect_mesh_primitive_layout(
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
) -> Result<Vec<AmbientEffectMeshPrimitive>, String> {
if frame.vertices.len() != frame.uv.len() || frame.indices.is_empty() {
return Err("ambient Op9 mesh has mismatched vertices/UVs or no indices".to_string());
}
let quad_count = frame
.indices
.len()
.checked_sub(frame.vertices.len())
.filter(|difference| difference.is_multiple_of(2))
.map(|difference| difference / 2)
.ok_or_else(|| "ambient Op9 mesh is not a native quad/cap layout".to_string())?;
let quad_vertex_count = quad_count
.checked_mul(4)
.ok_or_else(|| "ambient Op9 quad vertex count overflows".to_string())?;
let quad_index_count = quad_count
.checked_mul(6)
.ok_or_else(|| "ambient Op9 quad index count overflows".to_string())?;
let cap_vertex_count = frame
.vertices
.len()
.checked_sub(quad_vertex_count)
.filter(|count| count.is_multiple_of(3))
.ok_or_else(|| "ambient Op9 mesh has an invalid cap vertex count".to_string())?;
let cap_count = cap_vertex_count / 3;
let cap_index_count = cap_count
.checked_mul(3)
.ok_or_else(|| "ambient Op9 cap index count overflows".to_string())?;
if quad_index_count + cap_index_count != frame.indices.len() {
return Err("ambient Op9 quad and cap index counts do not match the frame".to_string());
}
// The native generator appends all quads first, then its triangle cap fan.
let mut primitives = Vec::with_capacity(quad_count + cap_count);
for quad in 0..quad_count {
let vertex_start = quad * 4;
let index_start = quad * 6;
let base = u16::try_from(vertex_start)
.map_err(|_| "ambient Op9 quad vertex exceeds u16".to_string())?;
let expected = [base, base + 1, base + 2, base + 2, base + 3, base];
if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
return Err("ambient Op9 quad topology changed from native ordering".to_string());
}
primitives.push(AmbientEffectMeshPrimitive {
vertex_start,
vertex_count: 4,
index_start,
index_count: 6,
});
}
for cap in 0..cap_count {
let vertex_start = quad_vertex_count + cap * 3;
let index_start = quad_index_count + cap * 3;
let base = u16::try_from(vertex_start)
.map_err(|_| "ambient Op9 cap vertex exceeds u16".to_string())?;
let expected = [base, base + 1, base + 2];
if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
return Err("ambient Op9 cap topology changed from native ordering".to_string());
}
primitives.push(AmbientEffectMeshPrimitive {
vertex_start,
vertex_count: 3,
index_start,
index_count: 3,
});
}
if primitives.is_empty() {
return Err("ambient Op9 mesh contains no native draw primitives".to_string());
}
Ok(primitives)
}
fn ambient_effect_mesh_primitive_vertices(
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
primitive: AmbientEffectMeshPrimitive,
) -> Vec<VulkanStaticVertex> {
let end = primitive.vertex_start + primitive.vertex_count;
frame.vertices[primitive.vertex_start..end]
.iter()
.zip(&frame.uv[primitive.vertex_start..end])
.map(|(position, uv)| {
environment_vertex_with_alpha(
*position,
[frame.color[0], frame.color[1], frame.color[2]],
frame.color[3].clamp(0.0, 1.0),
*uv,
)
})
.collect()
}
fn ambient_effect_mesh_primitive_indices(
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
primitive: AmbientEffectMeshPrimitive,
) -> Result<Vec<u32>, String> {
let index_end = primitive.index_start + primitive.index_count;
frame.indices[primitive.index_start..index_end]
.iter()
.map(|index| {
usize::from(*index)
.checked_sub(primitive.vertex_start)
.and_then(|local| u32::try_from(local).ok())
.ok_or_else(|| {
"ambient Op9 primitive index is outside its vertex block".to_string()
})
})
.collect()
}
fn write_ambient_effect_mesh_primitive(
mesh: &mut VulkanStaticMesh,
range: &EnvironmentGpuRange,
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
primitive: AmbientEffectMeshPrimitive,
) -> Result<(), String> {
if range.vertex_capacity != primitive.vertex_count {
return Err("ambient Op9 primitive vertex allocation changed".to_string());
}
let vertex_end = range
.vertex_start
.checked_add(range.vertex_capacity)
.ok_or_else(|| "ambient Op9 vertex range overflows".to_string())?;
let destination = mesh
.vertices
.get_mut(range.vertex_start..vertex_end)
.ok_or_else(|| "ambient Op9 vertex allocation is out of bounds".to_string())?;
let draw_range = mesh
.draw_ranges
.get(range.range_index)
.ok_or_else(|| "ambient Op9 draw range is out of bounds".to_string())?;
let index_start = usize::try_from(draw_range.first_index)
.map_err(|_| "ambient Op9 first index exceeds usize".to_string())?;
let index_end = index_start
.checked_add(primitive.index_count)
.ok_or_else(|| "ambient Op9 index range overflows".to_string())?;
let current_indices = mesh
.indices
.get(index_start..index_end)
.ok_or_else(|| "ambient Op9 index allocation is out of bounds".to_string())?;
let frame_indices = frame
.indices
.get(primitive.index_start..primitive.index_start + primitive.index_count)
.ok_or_else(|| "ambient Op9 source primitive index range is out of bounds".to_string())?;
for (current, local) in current_indices.iter().zip(frame_indices) {
let primitive_local = usize::from(*local)
.checked_sub(primitive.vertex_start)
.ok_or_else(|| "ambient Op9 source index leaves its primitive".to_string())?;
let expected = u32::try_from(range.vertex_start)
.ok()
.and_then(|base| base.checked_add(u32::try_from(primitive_local).ok()?))
.ok_or_else(|| "ambient Op9 primitive index exceeds u32".to_string())?;
if *current != expected {
return Err("ambient Op9 primitive topology changed after GPU preparation".to_string());
}
}
let frame_vertex_end = primitive
.vertex_start
.checked_add(primitive.vertex_count)
.ok_or_else(|| "ambient Op9 source primitive vertex range overflows".to_string())?;
let frame_vertices = frame
.vertices
.get(primitive.vertex_start..frame_vertex_end)
.ok_or_else(|| "ambient Op9 source primitive vertex range is out of bounds".to_string())?;
let frame_uvs = frame
.uv
.get(primitive.vertex_start..frame_vertex_end)
.ok_or_else(|| "ambient Op9 source primitive UV range is out of bounds".to_string())?;
for ((target, position), uv) in destination.iter_mut().zip(frame_vertices).zip(frame_uvs) {
*target = environment_vertex_with_alpha(
*position,
[frame.color[0], frame.color[1], frame.color[2]],
frame.color[3].clamp(0.0, 1.0),
*uv,
);
}
Ok(())
}
fn ambient_effect_vertex_rgb(command_rgb: [f32; 3], material_additive_rgb: [f32; 3]) -> [f32; 3] {
std::array::from_fn(|channel| command_rgb[channel] * material_additive_rgb[channel])
}
fn apply_ambient_effect_material_tints(
slots: &[AmbientEffectGpuSlot],
mesh: &mut VulkanStaticMesh,
updates: &[AmbientEffectGeometryUpdate],
active_materials: &HashMap<usize, ActiveMaterialState>,
) -> Result<(), String> {
for update in updates.iter().filter(|update| update.index_count > 0) {
let range = slots
.iter()
.find_map(|slot| ambient_effect_range_for(slot, update.range_index))
.ok_or_else(|| "ambient FX draw range has no owning slot".to_string())?;
let material = active_materials
.get(&update.range_index)
.ok_or_else(|| "ambient FX draw range has no active MAT0 phase".to_string())?;
let vertex_rgb = ambient_effect_vertex_rgb(update.command_rgb, material.additive_rgb);
let end = range
.vertex_start
.checked_add(range.vertex_capacity)
.ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
let vertices = mesh
.vertices
.get_mut(range.vertex_start..end)
.ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
for vertex in vertices {
vertex.color = vertex_rgb;
}
}
Ok(())
}
fn ambient_effect_draw_sort_keys(
mesh: &VulkanStaticMesh,
slots: &[AmbientEffectGpuSlot],
camera_world: [f32; 3],
) -> Result<Vec<(usize, f32)>, String> {
slots.iter().try_fold(Vec::new(), |mut keys, slot| {
for (range, _) in ambient_effect_ranges(slot) {
keys.push((
range.range_index,
first_indexed_vertex_distance(mesh, range.range_index, camera_world)?,
));
}
Ok(keys)
})
}
fn world_draw_range_indices(
draw_range_count: usize,
fixed_range_indices: &HashSet<usize>,
) -> Vec<usize> {
(0..draw_range_count)
.filter(|range_index| !fixed_range_indices.contains(range_index))
.collect()
}
// TEMPORARY diagnostic for ROADMAP.md A.4 (smoke_fr_02 speed). Enabled by
// FPARKAN_DIAG_SMOKE; identifies the emitter by its Op8 body (pool 30,
// interval 0.08) and reports spawn rate, particle lifetime and rise speed
// once per two seconds of effect clock. Remove after the investigation.
#[derive(Default)]
struct SmokeDiagnosticTrack {
window_start: Option<f32>,
last_born: f32,
spawns: usize,
calls: usize,
max_age: f32,
sum_age: f64,
sum_z: f64,
sum_age_z: f64,
sum_age_age: f64,
samples: usize,
}
fn smoke_fr_02_diagnostic(
slot: &AmbientEffectGpuSlot,
clock: f32,
phase: f32,
frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
) {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
static TRACKS: std::sync::OnceLock<
std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), SmokeDiagnosticTrack>>,
> = std::sync::OnceLock::new();
if !*ENABLED.get_or_init(|| std::env::var_os("FPARKAN_DIAG_SMOKE").is_some()) {
return;
}
let body = slot.command.raw_body.as_ref();
let read_u32 = |offset: usize| {
body.get(offset..offset + 4)
.map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes")))
};
if read_u32(0x20) != Some(30) || read_u32(0x14).map(f32::from_bits) != Some(0.08) {
return;
}
let key = (
slot.owner_object_index,
slot.owner_component_index,
slot.control_table_index,
slot.control_row_index,
slot.command_index,
);
let mut tracks = TRACKS
.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
.lock()
.expect("smoke diagnostic lock");
let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
let track = tracks.entry(key).or_default();
let window_start = *track.window_start.get_or_insert(clock);
track.calls += 1;
for frame in frames.iter().filter(|frame| frame.active) {
let born = clock - frame.elapsed_seconds;
if born > track.last_born + 1e-4 {
track.spawns += 1;
track.last_born = track.last_born.max(born);
}
let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
let age = f64::from(frame.elapsed_seconds);
track.max_age = track.max_age.max(frame.elapsed_seconds);
track.sum_age += age;
track.sum_z += f64::from(z);
track.sum_age_z += age * f64::from(z);
track.sum_age_age += age * age;
track.samples += 1;
}
let window = clock - window_start;
if window < 2.0 {
return;
}
let n = track.samples as f64;
let rise_per_second = if n > 1.0 {
(n * track.sum_age_z - track.sum_age * track.sum_z)
/ (n * track.sum_age_age - track.sum_age * track.sum_age)
} else {
0.0
};
let axis_lengths: [f32; 3] = std::array::from_fn(|column| {
(0..3)
.map(|row| slot.world_from_effect[row][column].powi(2))
.sum::<f32>()
.sqrt()
});
eprintln!(
"smoke_diag slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls={} calls_per_s={:.1} spawns={} spawns_per_s={:.2} active_now={} max_age={:.3}s rise_per_s={rise_per_second:.2} rise_per_life_2_4s={:.1} phase={phase:.3} emitter=[{:.2},{:.2},{:.2}] effect_axes={axis_lengths:.3?}",
track.calls,
track.calls as f32 / window,
track.spawns,
track.spawns as f32 / window,
frames.iter().filter(|frame| frame.active).count(),
track.max_age,
rise_per_second * 2.4,
slot.world_from_effect[0][3],
slot.world_from_effect[1][3],
slot.world_from_effect[2][3],
);
*track = SmokeDiagnosticTrack {
window_start: Some(clock),
last_born: track.last_born,
..SmokeDiagnosticTrack::default()
};
}
// TEMPORARY diagnostic for ROADMAP.md A.4 (animal "aura" speed). Enabled by
// FPARKAN_DIAG_FX_MATERIAL=<material name>, for example smoke_yg. Reports the
// effect-phase rate, call rate, particle lifetime and rise per particle life
// for every Op7/Op8 slot whose command uses that MAT0. Remove after the
// investigation.
#[derive(Default)]
struct FxSpeedDiagnosticTrack {
window_start: Option<f32>,
calls: usize,
phase_travel: f32,
last_phase: Option<f32>,
max_age: f32,
sum_t: f64,
sum_z: f64,
sum_tz: f64,
sum_tt: f64,
samples: usize,
}
fn fx_material_speed_diagnostic(
slot: &AmbientEffectGpuSlot,
material_name_offset: usize,
clock: f32,
phase: f32,
frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
) {
static WANTED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
static TRACKS: std::sync::OnceLock<
std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), FxSpeedDiagnosticTrack>>,
> = std::sync::OnceLock::new();
let Some(wanted) = WANTED
.get_or_init(|| {
std::env::var("FPARKAN_DIAG_FX_MATERIAL")
.ok()
.map(|name| name.to_ascii_lowercase())
})
.as_deref()
else {
return;
};
let body = slot.command.raw_body.as_ref();
let Some(name) = body.get(material_name_offset..material_name_offset + 32) else {
return;
};
let name = name.split(|byte| *byte == 0).next().unwrap_or_default();
if !name.eq_ignore_ascii_case(wanted.as_bytes()) {
return;
}
let key = (
slot.owner_object_index,
slot.owner_component_index,
slot.control_table_index,
slot.control_row_index,
slot.command_index,
);
let mut tracks = TRACKS
.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
.lock()
.expect("fx diagnostic lock");
let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
let track = tracks.entry(key).or_default();
let window_start = *track.window_start.get_or_insert(clock);
track.calls += 1;
if let Some(last_phase) = track.last_phase {
let delta = phase - last_phase;
track.phase_travel += if delta < -0.5 { delta + 1.0 } else { delta };
}
track.last_phase = Some(phase);
for frame in frames.iter().filter(|frame| frame.active) {
// Op8 reports the particle age in seconds; Op7 reports its normalized
// particle age, so the regression is per second or per life.
let t = if slot.command.opcode == FxOpcode::Op8 {
frame.elapsed_seconds
} else {
frame.normalized_time
};
let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
track.max_age = track.max_age.max(t);
track.sum_t += f64::from(t);
track.sum_z += f64::from(z);
track.sum_tz += f64::from(t) * f64::from(z);
track.sum_tt += f64::from(t) * f64::from(t);
track.samples += 1;
}
let window = clock - window_start;
if window < 3.0 {
return;
}
let n = track.samples as f64;
let slope = if n > 1.0 {
(n * track.sum_tz - track.sum_t * track.sum_z)
/ (n * track.sum_tt - track.sum_t * track.sum_t)
} else {
0.0
};
eprintln!(
"fx_diag material={wanted} op={:?} slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls_per_s={:.1} phase_rate_per_s={:.4} active_now={} max_age={:.3} rise_slope={slope:.2} ({}) duration={:.2} mode={} owner=[{:.1},{:.1},{:.1}]",
slot.command.opcode,
track.calls as f32 / window,
track.phase_travel / window,
frames.iter().filter(|frame| frame.active).count(),
track.max_age,
if slot.command.opcode == FxOpcode::Op8 { "units/s, age in s" } else { "units/life, age normalized" },
slot.duration_seconds,
slot.time_mode,
slot.world_from_effect[0][3],
slot.world_from_effect[1][3],
slot.world_from_effect[2][3],
);
*track = FxSpeedDiagnosticTrack {
window_start: Some(clock),
last_phase: track.last_phase,
..FxSpeedDiagnosticTrack::default()
};
}
fn ambient_effect_phase(
time_mode: u32,
header_flags: u32,
elapsed_seconds: f32,
duration_seconds: f32,
) -> Option<f32> {
if !elapsed_seconds.is_finite()
|| elapsed_seconds < 0.0
|| !duration_seconds.is_finite()
|| duration_seconds < 0.0
{
return None;
}
let mut phase = match time_mode {
0 => 0.0,
// The native phase stays raw through the optional 0x200/0x20
// transforms; the command-level 0x8120 path clamps only afterward.
1 if duration_seconds > 0.0 => elapsed_seconds / duration_seconds,
2 if duration_seconds > 0.0 => {
elapsed_seconds.rem_euclid(duration_seconds) / duration_seconds
}
_ => return None,
};
if header_flags & 0x200 != 0 && duration_seconds > 0.0 {
phase *= elapsed_seconds / duration_seconds;
}
if header_flags & 0x20 != 0 {
phase = if phase >= 0.5 {
(1.0 - phase) * 2.0
} else {
phase * 2.0
};
}
Some(phase)
}
fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * value[2] - z * value[1]);
let ty = 2.0 * (z * value[0] - x * value[2]);
let tz = 2.0 * (x * value[1] - y * value[0]);
[
value[0] + w * tx + (y * tz - z * ty),
value[1] + w * ty + (z * tx - x * tz),
value[2] + w * tz + (x * ty - y * tx),
]
}
fn multiply_unit_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
let [lx, ly, lz, lw] = left;
let [rx, ry, rz, rw] = right;
[
lw * rx + lx * rw + ly * rz - lz * ry,
lw * ry - lx * rz + ly * rw + lz * rx,
lw * rz + lx * ry - ly * rx + lz * rw,
lw * rw - lx * rx - ly * ry - lz * rz,
]
}
/// Projects the mission terrain plus every MSH component of the selected roots
/// into one shared world-space mesh.
///
/// Terrain resolves its packed low-byte base and high-byte overlay through
/// map-local `Land1.wea`, then pairs each layer with its `Land2.wea`
/// microtexture and type-18 UV stream. Type-14 alpha remains a continuous
/// interpolated overlay mask. MSH component hierarchy, animation sampling,
/// lightmaps, and gameplay visibility are handled by the preview bridge below.
fn static_preview_node_times(
node_count: usize,
bindings: &[PreparedControlNodeBinding],
) -> Result<Vec<Option<AnimationTime>>, String> {
let mut node_times = vec![Some(AnimationTime(0.0)); node_count];
for binding in bindings {
// Disabled rows are filtered while loading CTLD, but preserve the
// native guard here in case a caller constructs MissionAssets itself.
if binding.flags & 4 != 0 {
continue;
}
if binding.node_index >= node_count {
return Err(format!(
"control binding node {} is outside model node count {node_count}",
binding.node_index
));
}
if !binding.frame_a.is_finite()
|| !binding.frame_b.is_finite()
|| !binding.initial_blend.is_finite()
|| binding.frame_a < 0.0
|| binding.frame_b < 0.0
{
return Err(format!(
"control binding for node {} contains an invalid frame or blend",
binding.node_index
));
}
let mut blend = binding.initial_blend;
if binding.flags & 1 != 0 {
if blend > 1.0 {
blend -= 1.0;
}
if blend < 0.0 {
blend += 1.0;
}
} else {
blend = blend.clamp(0.0, 1.0);
}
if binding.flags & 2 != 0 {
blend = 1.0 - blend;
}
let sample_time = (1.0 - blend) * binding.frame_a + blend * binding.frame_b;
if !sample_time.is_finite() || sample_time < 0.0 {
return Err(format!(
"control binding for node {} produced a negative or non-finite sample time unsupported by the static sampler",
binding.node_index
));
}
// Source order is significant: native initialization writes each
// enabled row in turn, so a later row for the same node wins.
node_times[binding.node_index] = Some(AnimationTime(sample_time));
}
Ok(node_times)
}
fn static_preview_component_pose_buffer(
model: &ModelAsset,
bindings: &[PreparedControlNodeBinding],
static_animation_frame: Option<u16>,
) -> Result<Option<NodePoseBuffer>, String> {
if let Some(frame) = static_animation_frame {
return Ok(node38_sampled_hierarchy(model, frame));
}
let node_times = static_preview_node_times(model.node_count, bindings)?;
Ok(node38_sampled_hierarchy_at_times(model, &node_times)
.or_else(|| node38_fallback_hierarchy(model)))
}
fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option<f32>> {
control
.typed_records
.iter()
.filter(|record| record.tag == 21)
.map(|record| match &record.gate_provider {
PreparedControlGateProvider::Resolved {
initial_type1_value,
..
} if initial_type1_value.is_finite() => Some(*initial_type1_value),
_ => None,
})
.collect()
}
fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 {
if target_frames <= 1 {
return target_ms;
}
let last_frame = u128::from(target_frames - 1);
let frame = u128::from(frame_index).min(last_frame);
((u128::from(target_ms) * frame) / last_frame) as u32
}
fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 {
current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0
}
fn control_clock_mode_label(target_ms: Option<u32>) -> String {
match target_ms {
None => "realtime".to_string(),
Some(0) => "fixed-0ms".to_string(),
Some(target_ms) => format!("ramp-0-to-{target_ms}ms"),
}
}
fn write_readback_sequence_frame(
directory: &Path,
frame_index: u64,
control_clock_ms: u32,
artifact: &VulkanReadbackArtifact,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(directory).map_err(|error| {
format!(
"create readback sequence directory {}: {error}",
directory.display()
)
})?;
let path = directory.join(format!(
"frame-{frame_index:06}-clock-{control_clock_ms:010}ms-{}x{}.rgba",
artifact.width, artifact.height,
));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|error| format!("create readback frame {}: {error}", path.display()))?;
file.write_all(&artifact.bytes)
.map_err(|error| format!("write readback frame {}: {error}", path.display()))?;
Ok(path)
}
fn quaternion_yaw_z_degrees(rotation: [f32; 4]) -> Option<f32> {
if !rotation.iter().all(|value| value.is_finite()) {
return None;
}
let [x, y, z, w] = rotation;
let radians = (2.0 * (w * z + x * y)).atan2(1.0 - 2.0 * (y * y + z * z));
radians.is_finite().then_some(radians.to_degrees())
}
fn emit_control_animation_capture_diagnostics(
instances: &[Vec<Option<ControlAnimationInstance>>],
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
control_clock_ms: u32,
) {
for (object_index, components) in instances.iter().enumerate() {
for (component_index, instance) in components.iter().enumerate() {
let Some(instance) = instance.as_ref() else {
continue;
};
let mut gate_index = 0;
for record in instance
.control
.typed_records
.iter()
.filter(|record| record.tag == 21)
{
let mapped_row = record
.values
.first()
.and_then(|row| usize::try_from(*row).ok());
let timeline = mapped_row.and_then(|row| instance.control.timeline_rows.get(row));
let node_index = timeline.map(|row| row.node_index);
let sampled_time = node_index
.and_then(|node| instance.last_sampled_node_times.get(node))
.copied()
.flatten();
let pose = pose_buffers
.get(object_index)
.and_then(|items| items.get(component_index))
.and_then(Option::as_ref)
.and_then(|hierarchy| node_index.and_then(|node| hierarchy.poses.get(node)));
let rotation = pose.map(|pose| pose.rotation);
let yaw_z_degrees = rotation.and_then(quaternion_yaw_z_degrees);
let gate_value = instance.gate_values.get(gate_index).copied().flatten();
eprintln!(
"control_animation_capture object={object_index} component={component_index} clock_ms={control_clock_ms} gate_value={gate_value:?} gate_provider={:?} row={mapped_row:?} node={node_index:?} sampled_time={sampled_time:?} rotation={rotation:?} yaw_z_degrees={yaw_z_degrees:?}",
record.gate_provider,
);
gate_index += 1;
}
}
}
}
fn update_control_animation_pose_buffers(
instances: &mut [Vec<Option<ControlAnimationInstance>>],
pose_buffers: &mut [Vec<Option<NodePoseBuffer>>],
native_clock_ms: u32,
) -> Result<(), String> {
for (object_index, components) in instances.iter_mut().enumerate() {
let Some(object_pose_buffers) = pose_buffers.get_mut(object_index) else {
continue;
};
for (component_index, instance) in components.iter_mut().enumerate() {
let Some(instance) = instance.as_mut() else {
continue;
};
let node_times: Vec<Option<AnimationTime>> = match sample_control_node_times(
&instance.control,
native_clock_ms,
&instance.gate_values,
&mut instance.state,
) {
Ok(times) => {
instance.last_sampled_node_times = times.clone();
times
.into_iter()
.map(|time| time.map(AnimationTime))
.collect()
}
Err(
error @ (ControlSamplingError::UnsupportedTag21Mode { .. }
| ControlSamplingError::UnsupportedVariableMapping { .. }
| ControlSamplingError::MissingGateProvider { .. }
| ControlSamplingError::GateProviderKeyOutOfRange { .. }
| ControlSamplingError::MissingGateProviderValue { .. }
| ControlSamplingError::GateProviderCountMismatch { .. }),
) => {
if !instance.reported_unsupported_fallback {
eprintln!(
"control_animation_fallback object={object_index} component={component_index} error={error:?}"
);
instance.reported_unsupported_fallback = true;
}
let fallback_times = static_preview_node_times(
instance.control.model_node_count,
&instance.control.bindings,
)
.map_err(|fallback| {
format!(
"control animation fallback for object {object_index} component {component_index}: {fallback}"
)
})?;
instance.last_sampled_node_times = fallback_times
.iter()
.map(|time| time.map(|time| time.0))
.collect();
fallback_times
}
Err(error) => {
return Err(format!(
"control animation sample for object {object_index} component {component_index}: {error:?}"
));
}
};
let hierarchy = node38_sampled_hierarchy_at_times(&instance.model, &node_times)
.or_else(|| node38_fallback_hierarchy(&instance.model));
if let Some(hierarchy) = hierarchy {
if let Some(pose_buffer) = object_pose_buffers.get_mut(component_index) {
*pose_buffer = Some(hierarchy);
}
}
}
}
Ok(())
}
fn control_tree_chain_has_tag21(
component_index: usize,
tree: Option<&[fparkan_prototype::UnitComponentTreeNode]>,
controls: &[Option<PreparedComponentControl>],
) -> bool {
let mut current = Some(component_index);
let mut remaining = controls.len();
while let Some(index) = current {
if controls
.get(index)
.and_then(Option::as_ref)
.is_some_and(|control| control.typed_records.iter().any(|record| record.tag == 21))
{
return true;
}
if remaining == 0 {
return false;
}
remaining -= 1;
current = tree
.and_then(|nodes| nodes.get(index))
.and_then(|node| node.parent_index);
}
false
}
fn update_dynamic_component_geometry(
geometry: &[Vec<Option<DynamicComponentGeometry>>],
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
mount_poses: &mut [Vec<Option<VulkanNodePose>>],
mesh: &mut VulkanStaticMesh,
) -> Result<(), String> {
let identity_pose = VulkanNodePose {
translation: [0.0; 3],
rotation: [0.0, 0.0, 0.0, 1.0],
};
for (object_index, components) in geometry.iter().enumerate() {
for (component_index, component) in components.iter().enumerate() {
let Some(component) = component else {
continue;
};
if let Some((parent_index, socket)) = component.parent_mount {
let parent_mounted = mount_poses
.get(object_index)
.and_then(|items| items.get(parent_index))
.ok_or_else(|| {
format!("dynamic component {object_index}:{component_index} has no parent mount slot")
})?
.is_some();
let parent_hierarchy = pose_buffers
.get(object_index)
.and_then(|items| items.get(parent_index))
.and_then(Option::as_ref)
.ok_or_else(|| {
format!(
"dynamic component {object_index}:{component_index} has no parent pose"
)
})?;
let parent_socket = if parent_mounted {
node38_pose_relative_to_root_from_hierarchy(parent_hierarchy, socket)
} else {
node38_pose_from_hierarchy(parent_hierarchy, socket)
}
.ok_or_else(|| {
format!("dynamic component {object_index}:{component_index} parent socket {socket} is unavailable")
})?;
let parent_root = mount_poses
.get(object_index)
.and_then(|items| items.get(parent_index))
.copied()
.flatten()
.unwrap_or(identity_pose);
let mount = mount_poses
.get_mut(object_index)
.and_then(|items| items.get_mut(component_index))
.ok_or_else(|| {
format!(
"dynamic component {object_index}:{component_index} has no mount slot"
)
})?;
*mount = Some(compose_unit_pose(parent_root, parent_socket));
}
let hierarchy = pose_buffers
.get(object_index)
.and_then(|items| items.get(component_index))
.and_then(Option::as_ref)
.ok_or_else(|| {
format!(
"dynamic component {object_index}:{component_index} has no sampled pose"
)
})?;
let mount = mount_poses
.get(object_index)
.and_then(|items| items.get(component_index))
.copied()
.flatten();
let projected = match mount {
Some(mount) => {
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
&component.model,
component.root_transform,
component.owner_scale,
hierarchy,
mount,
)
}
None => project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
&component.model,
component.root_transform,
component.owner_scale,
hierarchy,
),
}
.map_err(|error| {
format!("reproject dynamic component {object_index}:{component_index}: {error}")
})?;
if projected.vertices.len() != component.vertex_count {
return Err(format!(
"dynamic component {object_index}:{component_index} changed vertex count from {} to {}",
component.vertex_count,
projected.vertices.len()
));
}
let end = component
.vertex_start
.checked_add(component.vertex_count)
.filter(|end| *end <= mesh.vertices.len())
.ok_or_else(|| {
format!(
"dynamic component {object_index}:{component_index} vertex span is invalid"
)
})?;
mesh.vertices[component.vertex_start..end].copy_from_slice(&projected.vertices);
}
}
Ok(())
}
struct MissionBasementFootprint {
object_index: usize,
outer: Vec<[f32; 2]>,
inner: Vec<[f32; 3]>,
flatten_inner_to_mean_outer_z: bool,
}
fn apply_basement_placement_deltas(
roots: &[MissionObjectDraft],
footprints: &[MissionBasementFootprint],
placements: &[BasementPlacement],
) -> Result<Vec<MissionObjectDraft>, String> {
if footprints.len() != placements.len() {
return Err("basement placement results are not aligned with mission roots".to_string());
}
let mut adjusted_roots = roots.to_vec();
for (footprint, placement) in footprints.iter().zip(placements) {
let root = adjusted_roots
.get_mut(footprint.object_index)
.ok_or_else(|| "basement footprint references an unknown mission root".to_string())?;
root.position = root.root_position_with_basement_delta(placement.placement_delta);
}
Ok(adjusted_roots)
}
fn mission_basement_footprints(
assets: &MissionAssets,
roots: &[MissionObjectDraft],
) -> Result<Vec<MissionBasementFootprint>, String> {
let mut footprints = Vec::new();
for (object_index, mission_root) in roots.iter().enumerate() {
let Some(mode) = mission_root.basement_placement_mode() else {
continue;
};
let Some(component_bas) = assets
.object_component_bas
.get(object_index)
.and_then(|components| components.first())
.and_then(Option::as_ref)
else {
continue;
};
let geometry = &component_bas.geometry;
let bas_label = format!(
"{}:{}",
component_bas.source.archive.as_str(),
String::from_utf8_lossy(&component_bas.source.name.0)
);
// shortcut: the installed Iron Strategy corpus has one inner and one
// outer ring per eligible DAT. Native iterates both arrays, so reject
// unsupported multiplicity until the multi-ring semantics are ported.
if geometry.first.len() != 1 || geometry.second.len() != 1 {
return Err(format!(
"eligible mission object {object_index} from {bas_label} has {} inner and {} outer BAS rings; one of each is supported",
geometry.first.len(),
geometry.second.len()
));
}
let transform = LegacyIron3dEulerTransform {
translation: mission_root.position,
orientation_radians: mission_root.orientation_raw,
};
let transform_points = |points: &[[f32; 3]], ring_name: &str| {
let mut transformed_points = Vec::with_capacity(points.len());
for &point in points {
let transformed = transform
.try_transform_scaled_point(point, mission_root.scale)
.ok_or_else(|| {
format!(
"building BAS {ring_name} ring on mission object {object_index} from {bas_label} has a non-finite transformed point"
)
})?;
transformed_points.push(transformed);
}
Ok::<_, String>(transformed_points)
};
let inner_record = &geometry.first[0];
let outer_record = &geometry.second[0];
for (ring_name, declared_count, points) in [
("inner", inner_record.declared_count, &inner_record.points),
("outer", outer_record.declared_count, &outer_record.points),
] {
let expected_point_count = usize::try_from(declared_count)
.ok()
.and_then(|count| count.checked_add(1))
.ok_or_else(|| format!("building BAS {ring_name} count overflows: {bas_label}"))?;
if usize::try_from(declared_count).unwrap_or(0) < 3
|| points.len() != expected_point_count
{
return Err(format!(
"building BAS {ring_name} ring on mission object {object_index} from {bas_label} has count {declared_count} and {} points",
points.len()
));
}
}
let inner = transform_points(&inner_record.points, "inner")?;
let outer_3d = transform_points(&outer_record.points, "outer")?;
footprints.push(MissionBasementFootprint {
object_index,
outer: outer_3d.iter().map(|point| [point[0], point[1]]).collect(),
inner,
flatten_inner_to_mean_outer_z: mode == BasementPlacementMode::FlattenInnerAndAdjustRoot,
});
}
Ok(footprints)
}
fn interpolate_static_terrain_vertex(
position: [f32; 3],
source: [VulkanStaticVertex; 3],
) -> Result<VulkanStaticVertex, String> {
let point = [f64::from(position[0]), f64::from(position[1])];
let a = [
f64::from(source[0].position[0]),
f64::from(source[0].position[1]),
];
let b = [
f64::from(source[1].position[0]),
f64::from(source[1].position[1]),
];
let c = [
f64::from(source[2].position[0]),
f64::from(source[2].position[1]),
];
let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if !denominator.is_finite() || denominator.abs() <= 1.0e-10 {
return Err("terrain source triangle has no stable XY barycentric coordinates".to_string());
}
let second_weight =
((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator;
let third_weight =
((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator;
let weights = [
1.0 - second_weight - third_weight,
second_weight,
third_weight,
];
if !weights.iter().all(|weight| weight.is_finite()) {
return Err("terrain split vertex has invalid barycentric coordinates".to_string());
}
let blend2 = |values: [[f32; 2]; 3]| {
[
(0..3)
.map(|index| values[index][0] * weights[index] as f32)
.sum(),
(0..3)
.map(|index| values[index][1] * weights[index] as f32)
.sum(),
]
};
let blend3 = |values: [[f32; 3]; 3]| {
std::array::from_fn(|axis| {
(0..3)
.map(|index| values[index][axis] * weights[index] as f32)
.sum()
})
};
let overlay_alpha = (0..3)
.map(|index| source[index].overlay_alpha * weights[index] as f32)
.sum::<f32>();
if !overlay_alpha.is_finite() {
return Err(format!(
"terrain split vertex overlay alpha is non-finite: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}",
source.map(|vertex| vertex.position),
source.map(|vertex| vertex.overlay_alpha)
));
}
const OVERLAY_ALPHA_ROUNDING_TOLERANCE: f32 = 1.0e-5;
if overlay_alpha < -OVERLAY_ALPHA_ROUNDING_TOLERANCE
|| overlay_alpha > 1.0 + OVERLAY_ALPHA_ROUNDING_TOLERANCE
{
return Err(format!(
"terrain split vertex overlay alpha exceeds barycentric rounding tolerance: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}",
source.map(|vertex| vertex.position),
source.map(|vertex| vertex.overlay_alpha)
));
}
let vertex = VulkanStaticVertex {
position,
color: blend3(source.map(|vertex| vertex.color)),
normal: blend3(source.map(|vertex| vertex.normal)),
uv: blend2(source.map(|vertex| vertex.uv)),
detail_uv: blend2(source.map(|vertex| vertex.detail_uv)),
overlay_alpha: overlay_alpha.clamp(0.0, 1.0),
};
if !vertex
.position
.iter()
.chain(vertex.color.iter())
.chain(vertex.normal.iter())
.chain(vertex.uv.iter())
.chain(vertex.detail_uv.iter())
.chain(std::iter::once(&vertex.overlay_alpha))
.all(|value| value.is_finite())
{
return Err(
"terrain split vertex contains a non-finite interpolated attribute".to_string(),
);
}
Ok(vertex)
}
fn project_basement_foundation(
terrain: &fparkan_terrain_format::LandMeshDocument,
source_mesh: &VulkanStaticMesh,
foundation: &mut BasementFoundation,
) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>), String> {
let pipeline_state = source_mesh
.draw_ranges
.first()
.map(|range| range.pipeline_state)
.ok_or_else(|| "terrain source mesh has no LOD0 draw ranges".to_string())?;
let (normals, uv0_words) = canonicalize_basement_band_vertices(foundation)?;
let mut mesh = VulkanStaticMesh {
vertices: Vec::new(),
indices: Vec::new(),
draw_ranges: Vec::new(),
};
let mut shadow_receivers = Vec::with_capacity(foundation.fragments.len());
for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
let (material_index, base_only, vertices) = match fragment.source_face {
Some(face_index) => {
let face = terrain.faces.get(face_index).ok_or_else(|| {
format!("foundation fragment references unknown terrain face {face_index}")
})?;
if face.flags != fragment.surface_mask {
return Err(format!(
"foundation fragment for terrain face {face_index} changed its surface mask"
));
}
let source_vertices: [VulkanStaticVertex; 3] = face
.vertices
.map(|vertex| {
source_mesh
.vertices
.get(usize::from(vertex))
.copied()
.ok_or_else(|| {
format!(
"terrain face {face_index} references an unknown projected vertex"
)
})
})
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.try_into()
.map_err(|_| "terrain face did not have three vertices".to_string())?;
let vertices = fragment
.positions
.map(|position| {
source_vertices
.iter()
.find(|vertex| vertex.position == position)
.copied()
.map(Ok)
.unwrap_or_else(|| {
interpolate_static_terrain_vertex(position, source_vertices)
})
})
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.try_into()
.map_err(|_| {
"foundation source fragment did not have three vertices".to_string()
})?;
(
face.material_tag,
fragment.surface_mask.0 & 0x400 == 0,
vertices,
)
}
None => {
let fragment_normals = normals
.get(fragment_index)
.and_then(Option::as_ref)
.ok_or_else(|| "basement band face has no native vertex normals".to_string())?;
let fragment_uv0_words = uv0_words
.get(fragment_index)
.and_then(Option::as_ref)
.ok_or_else(|| "basement band face lost its original UV words".to_string())?;
let vertices = basement_band_vertices(
fragment.positions,
fragment_uv0_words,
fragment_normals,
);
(0xff00, true, vertices)
}
};
let first_index = u32::try_from(mesh.indices.len())
.map_err(|_| "basement terrain index buffer exceeds u32".to_string())?;
for vertex in vertices {
let index = u32::try_from(mesh.vertices.len())
.map_err(|_| "basement terrain vertex buffer exceeds u32".to_string())?;
mesh.vertices.push(vertex);
mesh.indices.push(index);
}
if let Some(range) = mesh.draw_ranges.last_mut().filter(|range| {
range.material_index == material_index && range.terrain_base_only == base_only
}) {
range.index_count = range
.index_count
.checked_add(3)
.ok_or_else(|| "basement terrain draw range exceeds u32".to_string())?;
} else {
mesh.draw_ranges.push(VulkanStaticDrawRange {
first_index,
index_count: 3,
material_index,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state,
terrain_base_only: base_only,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
}
shadow_receivers.push(shadow_triangle(fragment.positions, fragment.surface_mask.0));
}
Ok((mesh, shadow_receivers))
}
fn prepare_static_terrain_geometry(
terrain: &fparkan_terrain_format::LandMeshDocument,
source_mesh: VulkanStaticMesh,
terrain_world: &TerrainWorld,
foundation: Option<BasementFoundation>,
) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>, TerrainWorld), String> {
let Some(mut foundation) = foundation else {
return Ok((
source_mesh,
terrain_shadow_receivers(terrain)?,
terrain_world.clone(),
));
};
let (mesh, shadow_receivers) =
project_basement_foundation(terrain, &source_mesh, &mut foundation)?;
let terrain_query = terrain_world
.clone()
.with_basement_foundation(foundation)
.map_err(|error| format!("install canonical terrain query surfaces: {error}"))?;
Ok((mesh, shadow_receivers, terrain_query))
}
#[derive(Clone, Copy, Debug)]
struct BasementBandVertex {
position: [f32; 3],
uv0: [i16; 2],
// The native callback leaves UV1 unwritten; zero is a deterministic
// assumption for matching generated vertices and is not native parity.
detail_uv: [i16; 2],
alpha: f32,
}
#[derive(Clone, Copy, Debug)]
struct BasementBandVertexGroup {
position: [f32; 3],
uv0: [i16; 2],
detail_uv: [i16; 2],
alpha: f32,
face_normal_sum: [i64; 3],
}
fn canonicalize_basement_band_vertices(
foundation: &mut BasementFoundation,
) -> Result<(Vec<Option<[[f32; 3]; 3]>>, Vec<Option<[[i16; 2]; 3]>>), String> {
let mut group_ids_by_fragment = vec![None; foundation.fragments.len()];
let mut uv0_words_by_fragment = vec![None; foundation.fragments.len()];
let mut groups = Vec::<BasementBandVertexGroup>::new();
let mut buckets = HashMap::<(i32, i32, i16, i16), Vec<usize>>::new();
// shortcut: installed-corpus probing found no exact UV0 weld key between
// generated bands and clipped source fragments. Extend the index to source
// vertices if a future corpus reaches that key.
for (fragment_index, fragment) in foundation.fragments.iter_mut().enumerate() {
if fragment.source_face.is_some() {
continue;
}
// Preserve the input UV words: canonicalizing a welded position can
// otherwise change this triangle's per-face UV origin.
let vertices = basement_band_uv_vertices(fragment.positions)?;
let mut group_ids = [0_usize; 3];
let mut uv0_words = [[0_i16; 2]; 3];
for (corner, vertex) in vertices.into_iter().enumerate() {
uv0_words[corner] = vertex.uv0;
let cell = basement_vertex_cell(vertex.position)?;
let mut candidates = Vec::new();
for dx in -1..=1 {
for dy in -1..=1 {
let (Some(x), Some(y)) = (cell.0.checked_add(dx), cell.1.checked_add(dy))
else {
continue;
};
if let Some(bucket) = buckets.get(&(x, y, vertex.uv0[0], vertex.uv0[1])) {
candidates.extend(bucket.iter().copied());
}
}
}
candidates.sort_unstable();
let group_index = candidates
.into_iter()
.find(|&index| {
let existing = groups[index];
existing.uv0 == vertex.uv0
&& existing.detail_uv == vertex.detail_uv
&& existing.alpha.to_bits() == vertex.alpha.to_bits()
&& basement_vertex_xy_distance(existing.position, vertex.position) < 0.01
})
.unwrap_or_else(|| {
let index = groups.len();
groups.push(BasementBandVertexGroup {
position: vertex.position,
uv0: vertex.uv0,
detail_uv: vertex.detail_uv,
alpha: vertex.alpha,
face_normal_sum: [0; 3],
});
buckets
.entry((cell.0, cell.1, vertex.uv0[0], vertex.uv0[1]))
.or_default()
.push(index);
index
});
fragment.positions[corner] = groups[group_index].position;
group_ids[corner] = group_index;
}
group_ids_by_fragment[fragment_index] = Some(group_ids);
uv0_words_by_fragment[fragment_index] = Some(uv0_words);
}
for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
if fragment.source_face.is_some() {
continue;
}
let Some(face_normal) = packed_basement_face_normal(fragment.positions)? else {
continue;
};
let group_ids = group_ids_by_fragment[fragment_index]
.ok_or_else(|| "basement band face has no welded vertex groups".to_string())?;
// The native normal postpass visits each indexed face once per vertex,
// even if malformed geometry gives two corners the same vertex index.
for group_index in group_ids.into_iter().collect::<HashSet<_>>() {
for axis in 0..3 {
groups[group_index].face_normal_sum[axis] += i64::from(face_normal[axis]);
}
}
}
let normalized = groups
.iter()
.map(|group| normalized_native_vertex_normal(group.face_normal_sum))
.collect::<Result<Vec<_>, _>>()?;
Ok((
group_ids_by_fragment
.into_iter()
.map(|group_ids| group_ids.map(|ids| ids.map(|group| normalized[group])))
.collect(),
uv0_words_by_fragment,
))
}
fn basement_vertex_cell(position: [f32; 3]) -> Result<(i32, i32), String> {
let x = (position[0] / 0.01).floor();
let y = (position[1] / 0.01).floor();
if !x.is_finite()
|| !y.is_finite()
|| x < i32::MIN as f32
|| x > i32::MAX as f32
|| y < i32::MIN as f32
|| y > i32::MAX as f32
{
return Err("basement band vertex is outside the native weld grid".to_string());
}
Ok((x as i32, y as i32))
}
fn basement_vertex_xy_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
((left[0] - right[0]).powi(2) + (left[1] - right[1]).powi(2)).sqrt()
}
fn basement_band_uv_vertices(positions: [[f32; 3]; 3]) -> Result<[BasementBandVertex; 3], String> {
let raw_uv = positions.map(|point| [point[0] * 0.066_f32, point[1] * 0.066_f32]);
if !raw_uv.iter().flatten().all(|value| value.is_finite()) {
return Err("basement band UV is non-finite".to_string());
}
let origin = std::array::from_fn::<_, 2, _>(|axis| {
raw_uv
.iter()
.map(|uv| uv[axis])
.fold(f32::INFINITY, f32::min)
.floor()
});
let mut result = [BasementBandVertex {
position: [0.0; 3],
uv0: [0; 2],
detail_uv: [0; 2],
alpha: 1.0,
}; 3];
for index in 0..3 {
let mut uv0 = [0_i16; 2];
for axis in 0..2 {
let scaled = ((raw_uv[index][axis] - origin[axis]) * 1024.0_f32).round_ties_even();
if !scaled.is_finite() || scaled < i64::MIN as f32 || scaled > i64::MAX as f32 {
return Err("basement band UV exceeds the native conversion range".to_string());
}
// Native FISTP stores the low signed 16-bit word from its integer result.
uv0[axis] = (scaled as i64) as i16;
}
result[index] = BasementBandVertex {
position: positions[index],
uv0,
detail_uv: [0; 2],
alpha: 1.0,
};
}
Ok(result)
}
fn packed_basement_face_normal(positions: [[f32; 3]; 3]) -> Result<Option<[i16; 3]>, String> {
let [a, b, c] = positions.map(|point| point.map(f64::from));
let first = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let second = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let cross = [
first[1] * second[2] - first[2] * second[1],
first[2] * second[0] - first[0] * second[2],
first[0] * second[1] - first[1] * second[0],
];
let length = cross.iter().map(|value| value * value).sum::<f64>().sqrt();
if !length.is_finite() {
return Err("basement band face normal is non-finite".to_string());
}
if length == 0.0 {
return Ok(None);
}
Ok(Some(cross.map(|value| {
((value / length * 32767.0).round_ties_even()) as i16
})))
}
fn normalized_native_vertex_normal(face_normal_sum: [i64; 3]) -> Result<[f32; 3], String> {
let normal = face_normal_sum.map(|value| value as f64);
let length = normal.iter().map(|value| value * value).sum::<f64>().sqrt();
if !length.is_finite() {
return Err("basement band vertex has a non-finite native face-normal sum".to_string());
}
if length == 0.0 {
return Ok([0.0; 3]);
}
Ok(normal.map(|value| {
let packed = (value / length * 127.0)
.round_ties_even()
.clamp(-127.0, 127.0) as i8;
f32::from(packed) / 128.0
}))
}
fn basement_band_vertices(
positions: [[f32; 3]; 3],
uv0_words: &[[i16; 2]; 3],
normals: &[[f32; 3]; 3],
) -> [VulkanStaticVertex; 3] {
std::array::from_fn(|index| VulkanStaticVertex {
position: positions[index],
color: [1.0; 3],
normal: normals[index],
uv: uv0_words[index].map(|value| f32::from(value) / 1024.0),
// The native callback leaves the secondary UV words unwritten. The
// renderer uses zero here as a deterministic assumption; generated
// faces take the base-only path, so the detail coordinate is not sampled.
detail_uv: [0.0; 2],
overlay_alpha: 1.0,
})
}
fn static_preview_mesh_and_materials(
assets: &MissionAssets,
terrain: &TerrainWorld,
roots: &[MissionObjectDraft],
legacy_camera: Option<LegacyCameraCapture>,
static_animation_frame: Option<u16>,
static_material_phase: Option<u16>,
root: &std::path::Path,
land_msh_path: &str,
) -> Result<StaticPreviewScene, String> {
let terrain_mesh = terrain
.source_mesh()
.ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?;
let mut mesh = VulkanStaticMesh {
vertices: Vec::new(),
indices: Vec::new(),
draw_ranges: Vec::new(),
};
let mut native_handedness_by_range = HashMap::new();
let mut type16_present_ranges = HashSet::new();
let mut materials = Vec::new();
let mut material_animations = Vec::new();
let mut ambient_effect_slots = Vec::new();
let mut control_point_light_slots = Vec::new();
let mut control_sound_slots = Vec::new();
let mut unsupported_control_sounds = 0;
let mut effect_documents = HashMap::new();
let mut effect_materials = HashMap::new();
let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len());
let mut component_mount_poses_by_object = Vec::with_capacity(roots.len());
let mut dynamic_component_geometry_by_object = Vec::with_capacity(roots.len());
let mut building_collision_bindings = Vec::new();
let mut collision_models =
HashMap::<fparkan_assets::AssetId<PreparedModel>, Arc<ModelAsset>>::new();
let mut control_animation_instances_by_object = Vec::with_capacity(roots.len());
let mut dynamic_shadow_casters = Vec::new();
let mut sun_occlusion_range_indices = Vec::new();
let mut shadow_casters = Vec::new();
let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root);
let source_terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh)
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
let footprint_sources = mission_basement_footprints(assets, roots)?;
let foundation = if footprint_sources.is_empty() {
None
} else {
let footprints = footprint_sources
.iter()
.map(|footprint| BasementFootprint {
outer: &footprint.outer,
inner: &footprint.inner,
flatten_inner_to_mean_outer_z: footprint.flatten_inner_to_mean_outer_z,
})
.collect::<Vec<_>>();
Some(
build_basement_foundations(terrain_mesh, &footprints)
.map_err(|error| format!("prepare mission basement foundations: {error}"))?,
)
};
let effective_roots = foundation
.as_ref()
.map(|foundation| {
apply_basement_placement_deltas(roots, &footprint_sources, &foundation.placements)
})
.transpose()?;
// Every visual, mounted component, effect, and shadow caster below uses this
// same root transform, so the native delta is applied once to the hierarchy.
let roots = effective_roots.as_deref().unwrap_or(roots);
let (terrain_component, shadow_receivers, terrain_query) = prepare_static_terrain_geometry(
terrain_mesh,
source_terrain_component,
terrain,
foundation,
)?;
let (terrain_component, terrain_lod) = terrain_lod::prepare(
terrain_mesh,
terrain_component,
terrain_query.basement_foundation(),
)?;
let terrain_materials = static_preview_terrain_base_materials(
&mut terrain_material_loader,
root,
land_msh_path,
&terrain_component,
&mut materials,
&mut material_animations,
static_material_phase,
)?;
append_static_preview_component(
&mut mesh,
terrain_component,
&terrain_materials,
Some(&mut material_animations),
)?;
let dynamic_vertex_start = mesh.vertices.len();
let mut mesh_components = 0;
for (object_index, mission_root) in roots.iter().enumerate() {
let visual_ids = assets.visuals_for_object(object_index);
let records = &mission_root.unit_components;
let tree = if records.is_empty() {
None
} else {
Some(unit_component_tree(records).map_err(|error| {
format!("decode unit component tree for mission object {object_index}: {error}")
})?)
};
if let Some(tree) = tree.as_ref() {
if tree.len() != visual_ids.len() {
return Err(format!(
"mission object {object_index} unit tree has {} records but {} visuals",
tree.len(),
visual_ids.len()
));
}
}
let component_refs = visual_ids
.iter()
.map(|visual_id| {
let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
format!(
"static preview root {object_index} references unknown visual {visual_id:?}"
)
})?;
let model = visual
.model_id
.map(|model_id| {
assets.model_by_id(model_id).ok_or_else(|| {
format!(
"static preview visual {visual_id:?} references unknown model {model_id:?}"
)
})
})
.transpose()?;
Ok((visual, model))
})
.collect::<Result<Vec<_>, String>>()?;
let component_controls = assets.object_component_controls.get(object_index);
if component_controls.is_some_and(|controls| controls.len() != component_refs.len()) {
return Err(format!(
"mission object {object_index} has {} visuals but {} component controls",
component_refs.len(),
component_controls.map_or(0, Vec::len)
));
}
let root_native_kind = component_refs
.first()
.and_then(|(visual, _)| native_shadow_kind(visual.source_type));
let root_sun_eligible = native_sun_object_kind_eligible(
component_refs
.first()
.and_then(|(visual, _)| visual.source_type),
);
let mut root_shadow_triangles = Vec::new();
let mut root_shadow_spheres = Vec::new();
let mut root_shadow_sphere_sources = Vec::new();
let mut root_shadow_draw_range_indices = Vec::new();
let mut component_mount_poses = vec![None; component_refs.len()];
let mut component_pose_buffers = vec![None; component_refs.len()];
let mut dynamic_component_geometry = vec![None; component_refs.len()];
let mut control_animation_instances = vec![None; component_refs.len()];
for (component_index, (visual, model)) in component_refs.iter().enumerate() {
let should_render = tree.as_ref().map_or(true, |_| {
unit_component_is_visible(component_index, visual.source_type)
});
let control = component_controls
.and_then(|controls| controls.get(component_index))
.and_then(Option::as_ref);
let Some(model) = *model else {
if legacy_camera.is_none() && root_native_kind == Some(3) && should_render {
return Err(format!(
"building collision object {object_index} component {component_index} has no model"
));
}
if component_index == 0 {
if let Some(control) = control {
append_initial_control_effect_slots(
root,
mission_root,
object_index,
component_index,
control,
None,
None,
&mut mesh,
&mut materials,
&mut material_animations,
&mut effect_documents,
&mut effect_materials,
&mut ambient_effect_slots,
&mut control_point_light_slots,
&mut control_sound_slots,
&mut unsupported_control_sounds,
)?;
}
}
continue;
};
let component_bindings = component_controls
.and_then(|controls| controls.get(component_index))
.and_then(Option::as_ref)
.map_or(&[][..], |control| control.bindings.as_slice());
if static_animation_frame.is_none() {
if let Some(control) = control {
control_animation_instances[component_index] = Some(ControlAnimationInstance {
control: control.clone(),
model: model.validated.clone(),
gate_values: initial_control_gate_values(control),
state: ControlInstanceState::default(),
last_sampled_node_times: Vec::new(),
reported_unsupported_fallback: false,
});
}
}
component_pose_buffers[component_index] = static_preview_component_pose_buffer(
&model.validated,
component_bindings,
static_animation_frame,
)?;
let timeline_moves_component = static_animation_frame.is_none()
&& control_tree_chain_has_tag21(
component_index,
tree.as_deref(),
component_controls.map_or(&[][..], |controls| controls.as_slice()),
);
// Native AniMesh only resolves global MSH node sockets for EXTO
// geometry. Internal INTO components use controller/equipment
// slots, whose `parent_or_link` values are not MSH node indices.
// Do not ask the MSH hierarchy to resolve hidden internal parts.
let mount_pose = if should_render {
if let Some(tree) = tree.as_ref() {
let record = records.get(component_index).ok_or_else(|| {
format!("unit component {component_index} is outside decoded records")
})?;
let parent_index = tree[component_index].parent_index;
match parent_index {
None => None,
Some(parent_index) => {
let parent_visible = component_refs.get(parent_index).is_some_and(
|(parent_visual, _)| {
unit_component_is_visible(
parent_index,
parent_visual.source_type,
)
},
);
if !parent_visible {
None
} else {
let parent_model = component_refs
.get(parent_index)
.and_then(|(_, model)| *model)
.ok_or_else(|| {
format!(
"unit component {component_index} parent {parent_index} has no model for mount socket"
)
})?;
let parent_hierarchy = component_pose_buffers
.get(parent_index)
.and_then(Option::as_ref);
let parent_is_mounted =
component_mount_poses[parent_index].is_some();
let socket =
usize::try_from(record.parent_or_link).map_err(|_| {
format!(
"unit component {component_index} has negative mount socket {}",
record.parent_or_link
)
})?;
let parent_socket = match parent_hierarchy {
Some(hierarchy) if parent_is_mounted => {
node38_pose_relative_to_root_from_hierarchy(
hierarchy, socket,
)
}
Some(hierarchy) => {
node38_pose_from_hierarchy(hierarchy, socket)
}
None if parent_is_mounted => node38_pose_relative_to_root(
&parent_model.validated,
static_animation_frame,
socket,
),
None => node38_pose(
&parent_model.validated,
static_animation_frame,
socket,
),
}
.ok_or_else(|| {
format!(
"unit component {component_index} mount socket {socket} is absent from parent {parent_index} (parent source={:?}, node_count={}, slots={}, animation={})",
parent_model.source,
parent_model.validated.node_count,
parent_model.validated.slots.len(),
parent_model.validated.animation.is_some(),
)
})?;
let parent_root_pose = component_mount_poses[parent_index]
.unwrap_or(VulkanNodePose {
translation: [0.0; 3],
rotation: [0.0, 0.0, 0.0, 1.0],
});
Some(compose_unit_pose(parent_root_pose, parent_socket))
}
}
}
} else {
None
}
} else {
None
};
if let Some(mount_pose) = mount_pose {
component_mount_poses[component_index] = Some(mount_pose);
}
// External tree components have a recovered mount pose; flat
// mission components and the root use the mission pose. Hidden
// INTO slots use controller links rather than MSH sockets, so
// their Control owner transform remains unresolved here.
let owner_pose_available =
component_index == 0 || tree.is_none() || mount_pose.is_some();
if owner_pose_available {
if let Some(control) = control {
append_initial_control_effect_slots(
root,
mission_root,
object_index,
component_index,
control,
component_pose_buffers[component_index].as_ref(),
mount_pose,
&mut mesh,
&mut materials,
&mut material_animations,
&mut effect_documents,
&mut effect_materials,
&mut ambient_effect_slots,
&mut control_point_light_slots,
&mut control_sound_slots,
&mut unsupported_control_sounds,
)?;
}
}
let transform = LegacyIron3dEulerTransform {
translation: mission_root.position,
orientation_radians: mission_root.orientation_raw,
};
if legacy_camera.is_none() && root_native_kind == Some(3) && should_render {
let shared_model = collision_models
.entry(model.id)
.or_insert_with(|| Arc::new(model.validated.clone()))
.clone();
building_collision_bindings.push(building_collision_binding(
object_index,
component_index,
format!("{:?}", model.source),
shared_model,
transform,
mission_root.scale,
mount_pose.is_some(),
)?);
}
if should_render {
let pose_buffer = component_pose_buffers[component_index].as_ref();
let mut component = match (pose_buffer, mount_pose, static_animation_frame) {
(Some(poses), Some(mount), _) => {
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
&model.validated,
transform,
mission_root.scale,
poses,
mount,
)
}
(Some(poses), None, _) => {
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
&model.validated,
transform,
mission_root.scale,
poses,
)
}
(None, Some(mount), Some(frame)) => {
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount(
&model.validated,
transform,
mission_root.scale,
frame,
mount,
)
}
(None, Some(mount), None) => {
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount(
&model.validated,
transform,
mission_root.scale,
mount,
)
}
(None, None, Some(frame)) => {
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
&model.validated,
transform,
mission_root.scale,
frame,
)
}
(None, None, None) => {
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
&model.validated,
transform,
mission_root.scale,
)
}
}
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
let component_handedness = native_msh_handedness(transform, mission_root.scale);
for range in &mut component.draw_ranges {
range.pipeline_state.cull =
native_msh_cull_mode(range.batch_flags, component_handedness);
}
let component_shadow_triangles = static_mesh_shadow_triangles(&component);
if root_native_kind.is_some() {
root_shadow_triangles.extend(component_shadow_triangles);
let node1_pose = mount_pose.and_then(|_| match pose_buffer {
Some(poses) => node38_pose_relative_to_root_from_hierarchy(poses, 1),
None => node38_pose_relative_to_root(
&model.validated,
static_animation_frame,
1,
),
});
if let Some(sphere) = mounted_shadow_sphere(
model.validated.bounding_sphere_center,
model.validated.bounding_sphere_radius,
mount_pose,
node1_pose,
) {
root_shadow_spheres.push(sphere);
root_shadow_sphere_sources.push(DynamicShadowSphereSource {
component_index,
center: model.validated.bounding_sphere_center,
radius: model.validated.bounding_sphere_radius,
mounted: mount_pose.is_some(),
});
}
}
let selector_remap = static_preview_component_materials(
assets,
visual,
&component,
static_material_phase,
&mut materials,
&mut material_animations,
)?;
let lightmap_textures = static_preview_lightmap_textures(assets, visual)?;
let range_start = mesh.draw_ranges.len();
let vertex_start = mesh.vertices.len();
let vertex_count = component.vertices.len();
append_static_preview_component_with_lightmaps(
&mut mesh,
component,
&selector_remap,
Some(&mut materials),
&lightmap_textures,
Some(&mut material_animations),
)?;
for range_index in range_start..mesh.draw_ranges.len() {
native_handedness_by_range.insert(range_index, component_handedness);
if model.validated.type16_present {
type16_present_ranges.insert(range_index);
}
}
if root_native_kind.is_some() {
root_shadow_draw_range_indices.extend(range_start..mesh.draw_ranges.len());
}
if timeline_moves_component {
let parent_mount = tree
.as_ref()
.and_then(|nodes| nodes.get(component_index))
.and_then(|node| node.parent_index)
.filter(|parent_index| {
component_mount_poses[*parent_index].is_some()
|| component_refs.get(*parent_index).is_some_and(
|(parent_visual, _)| {
unit_component_is_visible(
*parent_index,
parent_visual.source_type,
)
},
)
})
.map(|parent_index| {
let socket = usize::try_from(records[component_index].parent_or_link)
.expect("mount socket was validated above");
(parent_index, socket)
});
dynamic_component_geometry[component_index] = Some(DynamicComponentGeometry {
model: model.validated.clone(),
root_transform: transform,
owner_scale: mission_root.scale,
parent_mount,
vertex_start,
vertex_count,
});
}
if root_sun_eligible {
sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
}
mesh_components += 1;
}
}
if let Some(native_kind) =
root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind))
{
let caster_id = u32::try_from(shadow_casters.len())
.map_err(|_| "shadow caster id exceeds u32".to_string())?;
let root_transform = LegacyIron3dEulerTransform {
translation: mission_root.position,
orientation_radians: mission_root.orientation_raw,
};
if let Some(caster) = aggregate_shadow_caster(
caster_id,
native_kind,
root_transform,
mission_root.scale,
root_shadow_triangles,
&root_shadow_spheres,
) {
if dynamic_component_geometry.iter().any(Option::is_some) {
let component_draw_range_indices = root_shadow_draw_range_indices
.into_iter()
.filter(|range_index| {
mesh.draw_ranges
.get(*range_index)
.is_some_and(|range| range.index_count != 0)
})
.collect::<Vec<_>>();
if !component_draw_range_indices.is_empty() {
dynamic_shadow_casters.push(DynamicShadowCaster {
caster_index: shadow_casters.len(),
object_index,
root_transform,
owner_scale: mission_root.scale,
initial_sphere: caster.sphere,
component_draw_range_indices,
component_spheres: root_shadow_sphere_sources.clone(),
});
}
}
shadow_casters.push(caster);
}
}
component_pose_buffers_by_object.push(component_pose_buffers);
component_mount_poses_by_object.push(component_mount_poses);
dynamic_component_geometry_by_object.push(dynamic_component_geometry);
control_animation_instances_by_object.push(control_animation_instances);
}
if mesh_components == 0 {
return Err("selected static preview roots have no mesh-backed visual".to_string());
}
let free_camera = if legacy_camera.is_none() {
Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?)
} else {
None
};
let captured_viewport = legacy_camera
.as_ref()
.map(LegacyCameraCapture::captured_viewport)
.transpose()?;
let view_camera = if let Some(capture) = legacy_camera.as_ref() {
capture.preview_camera_frame()?
} else {
free_camera
.map(|camera| camera.preview_frame(16.0 / 9.0))
.ok_or_else(|| "static preview camera is unavailable".to_string())?
};
let camera = view_camera.vulkan_camera();
let shadow_material_index = append_shadow_material(&mut materials)?;
Ok(StaticPreviewScene {
mesh,
dynamic_vertex_start,
terrain_lod,
native_handedness_by_range,
type16_present_ranges,
materials,
material_animations,
ambient_effect_slots,
control_point_light_slots,
control_sound_slots,
unsupported_control_sounds,
component_pose_buffers: component_pose_buffers_by_object,
component_mount_poses: component_mount_poses_by_object,
dynamic_component_geometry: dynamic_component_geometry_by_object,
building_collision_bindings,
control_animation_instances: control_animation_instances_by_object,
dynamic_shadow_casters,
sun_occlusion_range_indices,
shadow_casters,
shadow_receivers,
shadow_material_index,
terrain: Arc::new(terrain_query),
camera,
view_camera,
captured_viewport,
free_camera,
camera_mode: if legacy_camera.is_some() {
"legacy-d3d7-capture"
} else {
"free-flight"
},
mesh_components,
terrain_components: 1,
})
}
fn static_preview_terrain_base_materials(
loader: &mut StandaloneWearMaterialLoader,
root: &std::path::Path,
land_msh_path: &str,
mesh: &VulkanStaticMesh,
materials: &mut Vec<VulkanStaticMaterial>,
material_animations: &mut Vec<MaterialAnimationBinding>,
static_material_phase: Option<u16>,
) -> Result<Vec<(u16, u16)>, String> {
let land_path = root.join(land_msh_path);
let land_parent = land_path
.parent()
.ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?;
let land1_path = land_parent.join("Land1.wea");
let land2_path = land_parent.join("Land2.wea");
let mut packed_tags = mesh
.draw_ranges
.iter()
.map(|range| range.material_index)
.collect::<Vec<_>>();
packed_tags.sort_unstable();
packed_tags.dedup();
packed_tags
.into_iter()
.map(|packed_tag| {
let layers = TerrainMaterialLayers::from_packed_tag(packed_tag);
let base_document = loader
.material_document(&land1_path, layers.base_selection().material_index)
.map_err(|err| format!("resolve terrain base MAT0 document: {err}"))?;
let detail_document = loader
.material_document(&land2_path, layers.detail_base_selection().material_index)
.map_err(|err| format!("resolve terrain detail MAT0 document: {err}"))?;
let overlay_selection = layers.overlay_selection();
let overlay_detail_selection = layers.detail_overlay_selection();
let overlay_document = overlay_selection
.map(|selection| loader.material_document(&land1_path, selection.material_index))
.transpose()
.map_err(|err| format!("resolve terrain overlay MAT0 document: {err}"))?;
let overlay_detail_document = overlay_detail_selection
.map(|selection| loader.material_document(&land2_path, selection.material_index))
.transpose()
.map_err(|err| format!("resolve terrain overlay detail MAT0 document: {err}"))?;
let base_phases = load_standalone_phase_set(
loader,
&land1_path,
layers.base_selection().material_index,
&base_document,
)?;
let detail_phases = load_standalone_phase_set(
loader,
&land2_path,
layers.detail_base_selection().material_index,
&detail_document,
)?;
let overlay_phases = overlay_selection
.map(|selection| {
load_standalone_phase_set(
loader,
&land1_path,
selection.material_index,
overlay_document.as_ref().expect("overlay document"),
)
})
.transpose()?;
let overlay_detail_phases = overlay_detail_selection
.map(|selection| {
load_standalone_phase_set(
loader,
&land2_path,
selection.material_index,
overlay_detail_document
.as_ref()
.expect("overlay detail document"),
)
})
.transpose()?;
let phase_count = base_phases.len();
if phase_count == 0 || detail_phases.is_empty() {
return Err(format!(
"terrain material tag 0x{packed_tag:04x} has incomplete phase tables"
));
}
if overlay_phases.as_ref().is_some_and(Vec::is_empty)
|| overlay_detail_phases.as_ref().is_some_and(Vec::is_empty)
{
return Err(format!(
"terrain material tag 0x{packed_tag:04x} has an empty optional phase table"
));
}
let initial_phase_index =
usize::from(static_material_phase.unwrap_or(0)).min(phase_count.saturating_sub(1));
let initial_detail_phase_index = if let Some(phase) = static_material_phase {
usize::from(phase).min(detail_phases.len().saturating_sub(1))
} else {
sample_material_phase(
&detail_document,
layers.detail_base_selection().animation_block_index,
0,
0,
0,
)
.map_err(|error| format!("sample terrain detail material: {error}"))?
.phase_index
};
let initial_overlay_phase_index = overlay_phases.as_ref().map(|phases| {
usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1))
});
let initial_overlay_detail_phase_index = overlay_detail_phases
.as_ref()
.zip(overlay_detail_document.as_ref())
.zip(overlay_detail_selection)
.map(|((phases, document), selection)| {
if let Some(phase) = static_material_phase {
Ok(usize::from(phase).min(phases.len().saturating_sub(1)))
} else {
sample_material_phase(document, selection.animation_block_index, 0, 0, 0)
.map(|sample| sample.phase_index)
.map_err(|error| {
format!("sample terrain overlay detail material: {error}")
})
}
})
.transpose()?;
let mut phase_material_indices = Vec::with_capacity(phase_count);
for phase_index in 0..phase_count {
let (base, base_phase) = &base_phases[phase_index];
// The base draw selector follows the base MAT0 timeline. The
// other descriptor stages start at their own initial phase;
// frame-time sampling below can then advance them separately.
let (detail, detail_phase) = &detail_phases[initial_detail_phase_index];
let overlay = overlay_phases
.as_ref()
.zip(initial_overlay_phase_index)
.map(|(phases, phase_index)| &phases[phase_index]);
let overlay_detail = overlay_detail_phases
.as_ref()
.zip(initial_overlay_detail_phase_index)
.map(|(phases, phase_index)| &phases[phase_index]);
let preview_selector = u16::try_from(materials.len()).map_err(|_| {
"static preview exceeds the available 16-bit material selector space"
.to_string()
})?;
materials.push(VulkanStaticMaterial {
material_index: preview_selector,
pipeline_category: base_phase.pipeline_category,
texture: base.clone(),
detail_texture: Some(detail.clone()),
overlay_texture: overlay.map(|(texture, _)| texture.clone()),
overlay_detail_texture: overlay_detail.map(|(texture, _)| texture.clone()),
diffuse_alpha: base_phase.diffuse_alpha,
overlay_diffuse_alpha: overlay.map(|(_, phase)| phase.diffuse_alpha),
directional_rgb: base_phase.directional_rgb,
additive_rgb: base_phase.additive_rgb,
uv_transform: base_phase.page_uv_transform,
overlay_directional_rgb: overlay.map(|(_, phase)| phase.directional_rgb),
overlay_additive_rgb: overlay.map(|(_, phase)| phase.additive_rgb),
specular_rgb: base_phase.specular_rgb,
specular_power: base_phase.power,
overlay_specular_rgb: overlay.map(|(_, phase)| phase.specular_rgb),
overlay_specular_power: overlay.map(|(_, phase)| phase.power),
detail_uv_transform: detail_phase.page_uv_transform,
overlay_uv_transform: overlay.map(|(_, phase)| phase.page_uv_transform),
overlay_detail_uv_transform: overlay_detail
.map(|(_, phase)| phase.page_uv_transform),
unlit: false,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
screen_tl: false,
});
phase_material_indices.push(usize::from(preview_selector));
}
let base_uvs = phase_uv_transforms_from_standalone(
loader,
&land1_path,
layers.base_selection().material_index,
&base_document,
)?;
let detail_uvs = phase_uv_transforms_from_standalone(
loader,
&land2_path,
layers.detail_base_selection().material_index,
&detail_document,
)?;
let overlay_uvs = match (&overlay_document, layers.overlay_selection()) {
(Some(document), Some(selection)) => phase_uv_transforms_from_standalone(
loader,
&land1_path,
selection.material_index,
document,
)?,
_ => Vec::new(),
};
let overlay_detail_uvs =
match (&overlay_detail_document, layers.detail_overlay_selection()) {
(Some(document), Some(selection)) => phase_uv_transforms_from_standalone(
loader,
&land2_path,
selection.material_index,
document,
)?,
_ => Vec::new(),
};
let stage_textures = MaterialStageTextures {
detail: Some(
detail_phases
.iter()
.map(|(texture, _)| texture.clone())
.collect(),
),
overlay: overlay_phases
.as_ref()
.map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()),
overlay_detail: overlay_detail_phases
.as_ref()
.map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()),
applied_material_phases: phase_material_indices
.iter()
.copied()
.map(|material_index| {
(
material_index,
[
initial_detail_phase_index,
initial_overlay_phase_index.unwrap_or(0),
initial_overlay_detail_phase_index.unwrap_or(0),
],
)
})
.collect(),
};
material_animations.push(MaterialAnimationBinding {
initial_phase_index: static_material_phase
.map_or(0, usize::from)
.min(base_document.phases.len().saturating_sub(1)),
normalized_phase_override: None,
ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
pipeline_category: base_phases[initial_phase_index].1.pipeline_category,
}],
base: MaterialPhaseBinding {
document: base_document,
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: packed_tag as u32,
frozen_phase_index: static_material_phase.map(usize::from),
phase_uv_transforms: base_uvs,
},
detail: Some(MaterialPhaseBinding {
document: detail_document,
animation_block_index: layers.detail_base_selection().animation_block_index,
wear_row_start_ms: 0,
random_state: packed_tag as u32 ^ 0x5A5A_A5A5,
frozen_phase_index: static_material_phase.map(usize::from),
phase_uv_transforms: detail_uvs,
}),
overlay: overlay_document.map(|document| MaterialPhaseBinding {
document,
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: packed_tag as u32 ^ 0xA5A5_5A5A,
frozen_phase_index: static_material_phase.map(usize::from),
phase_uv_transforms: overlay_uvs,
}),
overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding {
document,
animation_block_index: overlay_detail_selection
.expect("overlay detail selection")
.animation_block_index,
wear_row_start_ms: 0,
random_state: packed_tag as u32 ^ 0xC3C3_3C3C,
frozen_phase_index: static_material_phase.map(usize::from),
phase_uv_transforms: overlay_detail_uvs,
}),
stage_textures: Some(stage_textures),
});
let preview_selector =
u16::try_from(materials.len() - phase_count + initial_phase_index)
.map_err(|_| "terrain material selector exceeds u16".to_string())?;
Ok((packed_tag, preview_selector))
})
.collect()
}
fn static_preview_component_materials(
assets: &MissionAssets,
visual: &PreparedVisual,
mesh: &VulkanStaticMesh,
static_material_phase: Option<u16>,
materials: &mut Vec<VulkanStaticMaterial>,
material_animations: &mut Vec<MaterialAnimationBinding>,
) -> Result<Vec<(u16, u16)>, String> {
let mut source_selectors = mesh
.draw_ranges
.iter()
.map(|range| range.material_index)
.collect::<Vec<_>>();
source_selectors.sort_unstable();
source_selectors.dedup();
source_selectors
.into_iter()
.map(|source_selector| {
let material_id = visual
.material_ids
.get(usize::from(source_selector))
.ok_or_else(|| {
format!(
"static preview MSH batch selector {source_selector} has no prepared WEAR material"
)
})?;
let material = assets.material_by_id(*material_id).ok_or_else(|| {
format!("static preview prepared material {source_selector} is unavailable")
})?;
let initial_phase_index = usize::from(static_material_phase.unwrap_or(0))
.min(material.mat0.phases.len().saturating_sub(1));
if material.mat0.phases.is_empty() {
return Err(format!(
"static preview material {source_selector} has no MAT0 phases"
));
}
let mut phase_material_indices = Vec::with_capacity(material.mat0.phases.len());
for phase in &material.mat0.phases {
let texture_name = phase_texture_name(phase);
let prepared_texture = texture_name.as_ref().and_then(|name| {
assets.textures.iter().find(|texture| {
texture.usage == PreparedTextureUsage::Diffuse
&& texture.source.name == *name
})
});
// Empty MAT0 texture names remain valid untextured materials.
let texture = prepared_texture
.map(|texture| {
prepared_texture_to_vulkan(texture, "static preview diffuse texture")
})
.transpose()?
.unwrap_or_else(|| solid_environment_texture([255, 255, 255, 255]));
let coefficients = phase.coefficients();
let uv_transform = if let Some(texture) = prepared_texture {
page_uv_transform(
texture.texm.width(),
texture.texm.height(),
&texture.texm.page_rects(),
coefficients.page_index,
)?
} else {
[0.0, 0.0, 1.0, 1.0]
};
let preview_selector = u16::try_from(materials.len()).map_err(|_| {
"static preview exceeds the available 16-bit material selector space"
.to_string()
})?;
materials.push(VulkanStaticMaterial {
material_index: preview_selector,
pipeline_category: material.pipeline_category,
texture,
detail_texture: None,
overlay_texture: None,
overlay_detail_texture: None,
diffuse_alpha: coefficients.opacity,
overlay_diffuse_alpha: None,
directional_rgb: coefficients.directional_rgb,
additive_rgb: coefficients.additive_rgb,
uv_transform,
overlay_directional_rgb: None,
overlay_additive_rgb: None,
specular_rgb: coefficients.specular_rgb,
specular_power: coefficients.power,
overlay_specular_rgb: None,
overlay_specular_power: None,
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
overlay_uv_transform: None,
overlay_detail_uv_transform: None,
unlit: false,
sky_nebula_stars: false,
lightmap_mode: false,
sky_far_depth: false,
screen_tl: false,
});
phase_material_indices.push(usize::from(preview_selector));
}
let preview_selector = u16::try_from(
*phase_material_indices
.get(initial_phase_index)
.ok_or_else(|| "MAT0 phase variant table is empty".to_string())?,
)
.map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?;
material_animations.push(MaterialAnimationBinding {
initial_phase_index,
normalized_phase_override: None,
ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices,
range_indices: Vec::new(),
pipeline_category: material.pipeline_category,
}],
base: MaterialPhaseBinding {
document: material.mat0.clone(),
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: material_id.raw() as u32,
frozen_phase_index: static_material_phase.map(usize::from),
phase_uv_transforms: phase_uv_transforms_from_assets(&material.mat0, assets),
},
detail: None,
overlay: None,
overlay_detail: None,
stage_textures: None,
});
Ok((source_selector, preview_selector))
})
.collect()
}
fn prepared_texture_to_vulkan(
texture: &PreparedTexture,
label: &str,
) -> Result<VulkanStaticTexture, String> {
let mip_images = (0..texture.texm.mip_count())
.map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX)))
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("decode {label} {:?}: {err}", texture.source.name))?;
let image = mip_images
.first()
.ok_or_else(|| format!("{label} {:?} has no mip zero", texture.source.name))?;
Ok(VulkanStaticTexture {
width: image.width,
height: image.height,
rgba8: image.rgba8.clone(),
mip_levels: mip_images
.into_iter()
.map(|image| VulkanStaticTextureMip {
width: image.width,
height: image.height,
rgba8: image.rgba8,
})
.collect(),
})
}
fn static_preview_lightmap_textures(
assets: &MissionAssets,
visual: &PreparedVisual,
) -> Result<Vec<VulkanStaticTexture>, String> {
visual
.lightmap_ids
.iter()
.map(|id| {
let texture = assets
.textures
.iter()
.find(|texture| {
texture.id == *id && texture.usage == PreparedTextureUsage::Lightmap
})
.ok_or_else(|| format!("static preview lightmap {id:?} is unavailable"))?;
prepared_texture_to_vulkan(texture, "static preview lightmap")
})
.collect()
}
fn append_static_preview_component(
target: &mut VulkanStaticMesh,
component: VulkanStaticMesh,
selector_remap: &[(u16, u16)],
material_animations: Option<&mut Vec<MaterialAnimationBinding>>,
) -> Result<(), String> {
append_static_preview_component_with_lightmaps(
target,
component,
selector_remap,
None,
&[],
material_animations,
)
}
fn append_static_preview_component_with_lightmaps(
target: &mut VulkanStaticMesh,
component: VulkanStaticMesh,
selector_remap: &[(u16, u16)],
mut materials: Option<&mut Vec<VulkanStaticMaterial>>,
lightmap_textures: &[VulkanStaticTexture],
mut material_animations: Option<&mut Vec<MaterialAnimationBinding>>,
) -> Result<(), String> {
let vertex_base = u32::try_from(target.vertices.len())
.map_err(|_| "static preview vertex count exceeds u32".to_string())?;
let first_index_base = u32::try_from(target.indices.len())
.map_err(|_| "static preview index count exceeds u32".to_string())?;
target
.vertices
.len()
.checked_add(component.vertices.len())
.ok_or_else(|| "static preview vertex count exceeds addressable memory".to_string())?;
target.vertices.extend(component.vertices);
target.indices.extend(
component
.indices
.into_iter()
.map(|index| {
index
.checked_add(vertex_base)
.ok_or_else(|| "static preview vertex index exceeds u32".to_string())
})
.collect::<Result<Vec<_>, _>>()?,
);
// A lightmapped range must keep the lightmap material family when MAT0
// advances to another phase. The range's original selector identifies
// the base binding, while the lightmap selector identifies the secondary
// texture. Keep the generated phase family and the binding/variant it
// belongs to so update_material_animations can rebind every frame.
let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new();
for range in component.draw_ranges {
let source_material_index = selector_remap
.iter()
.find_map(|(source, preview)| (*source == range.material_index).then_some(*preview))
.ok_or_else(|| {
format!(
"static preview component has no material remap for selector {}",
range.material_index
)
})?;
let (material_index, lightmap_variant) = if lightmap_textures
.get(usize::from(range.lightmap_index))
.is_some()
{
let key = (usize::from(source_material_index), range.lightmap_index);
let (phase_material_indices, binding_index, variant_index) = if let Some(existing) =
lightmap_materials.get(&key)
{
existing.clone()
} else {
let bindings = material_animations.as_deref().ok_or_else(|| {
"static preview lightmap animation storage is unavailable".to_string()
})?;
let (binding_index, source_phase_indices) =
find_material_animation_variant(bindings, usize::from(source_material_index))
.ok_or_else(|| {
format!(
"static preview material selector {source_material_index} has no MAT0 animation binding"
)
})?;
let initial_phase_index = bindings[binding_index].initial_phase_index;
let lightmap = lightmap_textures
.get(usize::from(range.lightmap_index))
.expect("lightmap presence checked above");
let materials = materials.as_mut().ok_or_else(|| {
"static preview lightmap material storage is unavailable".to_string()
})?;
let mut phase_material_indices = Vec::with_capacity(source_phase_indices.len());
for source_phase_index in source_phase_indices {
let mut lightmap_material = materials
.get(source_phase_index)
.cloned()
.ok_or_else(|| {
format!(
"static preview material selector {source_phase_index} is unavailable"
)
})?;
let original_directional = lightmap_material.directional_rgb;
lightmap_material.directional_rgb = [0.0; 3];
lightmap_material.additive_rgb = original_directional;
lightmap_material.detail_texture = Some(lightmap.clone());
lightmap_material.detail_uv_transform = [0.0, 0.0, 1.0, 1.0];
lightmap_material.overlay_texture = None;
lightmap_material.overlay_detail_texture = None;
lightmap_material.overlay_diffuse_alpha = None;
lightmap_material.overlay_directional_rgb = None;
lightmap_material.overlay_additive_rgb = None;
lightmap_material.overlay_uv_transform = None;
lightmap_material.overlay_detail_uv_transform = None;
lightmap_material.unlit = false;
lightmap_material.sky_nebula_stars = false;
lightmap_material.lightmap_mode = true;
let index = u16::try_from(materials.len()).map_err(|_| {
"static preview exceeds the available 16-bit material selector space"
.to_string()
})?;
lightmap_material.material_index = index;
materials.push(lightmap_material);
phase_material_indices.push(usize::from(index));
}
let initial_selector = phase_material_indices
.get(initial_phase_index)
.copied()
.ok_or_else(|| "static preview lightmap phase table is empty".to_string())?;
let pipeline_category = materials
.get(initial_selector)
.map(|material| material.pipeline_category)
.ok_or_else(|| {
"static preview lightmap material selector is unavailable".to_string()
})?;
let bindings = material_animations.as_deref_mut().ok_or_else(|| {
"static preview lightmap animation storage is unavailable".to_string()
})?;
let variant_index = bindings[binding_index].variants.len();
bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: phase_material_indices.clone(),
range_indices: Vec::new(),
pipeline_category,
});
let value = (phase_material_indices, binding_index, variant_index);
lightmap_materials.insert(key, value.clone());
value
};
let initial_material = *phase_material_indices
.get(
material_animations
.as_deref()
.and_then(|bindings| bindings.get(binding_index))
.map_or(0, |binding| binding.initial_phase_index),
)
.ok_or_else(|| "static preview lightmap phase table is empty".to_string())?;
(
u16::try_from(initial_material).map_err(|_| {
"static preview lightmap material selector exceeds u16".to_string()
})?,
Some((binding_index, variant_index)),
)
} else {
(source_material_index, None)
};
let target_range_index = target.draw_ranges.len();
target
.draw_ranges
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
first_index: first_index_base
.checked_add(range.first_index)
.ok_or_else(|| "static preview index range exceeds u32".to_string())?,
material_index,
..range
});
if let Some(bindings) = material_animations.as_deref_mut() {
if let Some((binding_index, variant_index)) = lightmap_variant {
bindings
.get_mut(binding_index)
.and_then(|binding| binding.variants.get_mut(variant_index))
.ok_or_else(|| {
"static preview lightmap animation variant is missing".to_string()
})?
.range_indices
.push(target_range_index);
} else {
register_material_range(
bindings,
usize::from(source_material_index),
target_range_index,
);
}
}
}
Ok(())
}
fn load_standalone_vulkan_texture(
loader: &mut StandaloneWearMaterialLoader,
wear_path: &Path,
material_index: u16,
phase_index: u16,
) -> Result<(VulkanStaticTexture, PreparedMaterialPhase), String> {
let (mips, phase) = loader.load(wear_path, material_index, phase_index)?;
let first = mips
.first()
.ok_or_else(|| "standalone TEXM mip loader returned no mip zero".to_string())?;
Ok((
VulkanStaticTexture {
width: first.width,
height: first.height,
rgba8: first.rgba8.clone(),
mip_levels: mips
.into_iter()
.map(|image| VulkanStaticTextureMip {
width: image.width,
height: image.height,
rgba8: image.rgba8,
})
.collect(),
},
phase,
))
}
fn load_standalone_phase_set(
loader: &mut StandaloneWearMaterialLoader,
wear_path: &Path,
material_index: u16,
document: &Mat0Document,
) -> Result<Vec<(VulkanStaticTexture, PreparedMaterialPhase)>, String> {
(0..document.phases.len())
.map(|phase_index| {
load_standalone_vulkan_texture(
loader,
wear_path,
material_index,
u16::try_from(phase_index)
.map_err(|_| "MAT0 phase index exceeds u16".to_string())?,
)
})
.collect()
}
fn run_static_vulkan_mode(
preview: StaticPreviewScene,
environment: Option<DynamicEnvironment>,
audio: Option<audio::GameAudio>,
target_frames: u64,
mission: &str,
object_count: usize,
readback_out: Option<&std::path::Path>,
readback_sequence_out: Option<&std::path::Path>,
enable_validation: bool,
control_clock_target_ms: Option<u32>,
startup_timings: StartupTimings,
) -> Result<String, String> {
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
event_loop.set_control_flow(ControlFlow::Poll);
let mut preview = preview;
let mut environment = environment;
// Reserve the projected-shadow draw range before the environment builder
// reorders its fixed passes. The range itself remains a stable draw slot;
// frame geometry is appended after every static environment allocation.
let mut shadow_range = append_environment_range(
&mut preview.mesh,
preview.shadow_material_index,
shadow_placeholder_vertices(),
quad_indices(1)?,
projected_shadow_pipeline_state(),
)?;
let environment_gpu = match environment.as_mut() {
Some(environment) => {
let camera = preview.view_camera;
let environment_materials = environment.materials.clone();
let material_assets = environment.material_assets.clone();
let sky_mesh = environment.sky.mesh().clone();
Some(EnvironmentGpuScene::new(
&mut preview.mesh,
&mut preview.native_handedness_by_range,
&mut preview.type16_present_ranges,
&mut preview.materials,
&mut preview.material_animations,
&mut preview.ambient_effect_slots,
&mut preview.dynamic_shadow_casters,
&mut preview.sun_occlusion_range_indices,
&environment_materials,
&material_assets,
&sky_mesh,
&camera,
&mut shadow_range.range_index,
)?)
}
None => None,
};
let shadow_base_vertex = preview.mesh.vertices.len();
let shadow_base_index = preview.mesh.indices.len();
let mut app = StaticVulkanApp::new(
preview,
environment,
environment_gpu,
shadow_range,
shadow_base_vertex,
shadow_base_index,
audio,
target_frames,
mission,
object_count,
readback_out.map(std::path::Path::to_path_buf),
readback_sequence_out.map(std::path::Path::to_path_buf),
enable_validation,
control_clock_target_ms,
startup_timings,
);
if let Err(err) = event_loop.run_app(&mut app) {
app.error = Some(format!("winit event loop: {err}"));
}
app.finish()
}
struct StaticVulkanApp {
mesh: VulkanStaticMesh,
dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
native_handedness_by_range: HashMap<usize, i8>,
type16_present_ranges: HashSet<usize>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
control_point_light_slots: Vec<ControlEffectSlot>,
control_sound_slots: Vec<ControlSoundSlot>,
unsupported_control_sounds: usize,
control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
building_collision_bindings: Vec<BuildingCollisionBinding>,
control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
dynamic_shadow_casters: Vec<DynamicShadowCaster>,
sun_occlusion_range_indices: Vec<usize>,
terrain: Arc<TerrainWorld>,
world_range_indices: Vec<usize>,
world_range_index_counts: HashMap<usize, u32>,
camera: VulkanStaticCamera,
view_camera: PreviewCameraFrame,
captured_viewport: Option<CapturedViewport>,
free_camera: Option<FreeFlightCamera>,
environment: Option<DynamicEnvironment>,
environment_gpu: Option<EnvironmentGpuScene>,
shadow_range: EnvironmentGpuRange,
shadow_base_vertex: usize,
shadow_base_index: usize,
shadow_material_index: usize,
shadow_casters: Vec<ShadowCaster>,
shadow_receivers: Vec<ShadowTriangle>,
shadow_cache: ShadowPageCache,
shadow_last_evidence: ShadowFrameEvidence,
last_environment_control_clock_ms: u32,
control_point_lights_diagnostic_reported: bool,
simulation_last_tick: Instant,
live_clock_paused_for_zero_extent: bool,
simulation_elapsed: Duration,
simulation_time_ms: u32,
simulation_update_generation: u64,
pending_scene_update: Option<SceneFrameUpdate>,
environment_primitive_count: usize,
pressed_keys: HashSet<KeyCode>,
mouse_look: bool,
audio: Option<audio::GameAudio>,
camera_mode: &'static str,
mesh_components: usize,
terrain_components: usize,
target_frames: u64,
control_clock_target_ms: Option<u32>,
mission: String,
object_count: usize,
readback_out: Option<PathBuf>,
readback_sequence_out: Option<PathBuf>,
enable_validation: bool,
startup_timings: StartupTimings,
window_id: Option<WindowId>,
window: Option<Window>,
renderer: Option<VulkanSmokeRenderer>,
frames_presented: u64,
output: Option<String>,
error: Option<String>,
}
impl StaticVulkanApp {
fn new(
preview: StaticPreviewScene,
environment: Option<DynamicEnvironment>,
environment_gpu: Option<EnvironmentGpuScene>,
shadow_range: EnvironmentGpuRange,
shadow_base_vertex: usize,
shadow_base_index: usize,
audio: Option<audio::GameAudio>,
target_frames: u64,
mission: &str,
object_count: usize,
readback_out: Option<PathBuf>,
readback_sequence_out: Option<PathBuf>,
enable_validation: bool,
control_clock_target_ms: Option<u32>,
startup_timings: StartupTimings,
) -> Self {
let mut fixed_range_indices = environment_gpu
.as_ref()
.map(EnvironmentGpuScene::fixed_range_indices)
.unwrap_or_default();
fixed_range_indices.extend(preview.ambient_effect_slots.iter().flat_map(|slot| {
std::iter::once(slot.range.range_index).chain(
slot.additional_particle_ranges
.iter()
.map(|range| range.range_index),
)
}));
fixed_range_indices.insert(shadow_range.range_index);
let world_range_indices =
world_draw_range_indices(preview.mesh.draw_ranges.len(), &fixed_range_indices);
let world_range_index_counts = preview
.mesh
.draw_ranges
.iter()
.enumerate()
.filter(|(range_index, _)| !fixed_range_indices.contains(range_index))
.map(|(range_index, range)| (range_index, range.index_count))
.collect();
Self {
mesh: preview.mesh,
dynamic_vertex_start: preview.dynamic_vertex_start,
terrain_lod: preview.terrain_lod,
native_handedness_by_range: preview.native_handedness_by_range,
type16_present_ranges: preview.type16_present_ranges,
materials: preview.materials,
material_animations: preview.material_animations,
ambient_effect_slots: preview.ambient_effect_slots,
control_point_light_slots: preview.control_point_light_slots,
control_sound_slots: preview.control_sound_slots,
unsupported_control_sounds: preview.unsupported_control_sounds,
control_effect_random_states: HashMap::new(),
component_pose_buffers: preview.component_pose_buffers,
component_mount_poses: preview.component_mount_poses,
dynamic_component_geometry: preview.dynamic_component_geometry,
building_collision_bindings: preview.building_collision_bindings,
control_animation_instances: preview.control_animation_instances,
dynamic_shadow_casters: preview.dynamic_shadow_casters,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
terrain: preview.terrain,
world_range_indices,
world_range_index_counts,
camera: preview.camera,
view_camera: preview.view_camera,
captured_viewport: preview.captured_viewport,
free_camera: preview.free_camera,
environment,
environment_gpu,
shadow_base_vertex,
shadow_base_index,
shadow_material_index: preview.shadow_material_index,
shadow_range,
shadow_casters: preview.shadow_casters,
shadow_receivers: preview.shadow_receivers,
shadow_cache: ShadowPageCache::default(),
shadow_last_evidence: ShadowFrameEvidence::default(),
last_environment_control_clock_ms: 0,
control_point_lights_diagnostic_reported: false,
simulation_last_tick: Instant::now(),
live_clock_paused_for_zero_extent: false,
simulation_elapsed: Duration::ZERO,
simulation_time_ms: 0,
simulation_update_generation: 0,
pending_scene_update: None,
environment_primitive_count: 0,
pressed_keys: HashSet::new(),
mouse_look: false,
audio,
camera_mode: preview.camera_mode,
mesh_components: preview.mesh_components,
terrain_components: preview.terrain_components,
target_frames,
control_clock_target_ms,
mission: mission.to_string(),
object_count,
readback_out,
readback_sequence_out,
enable_validation,
startup_timings,
window_id: None,
window: None,
renderer: None,
frames_presented: 0,
output: None,
error: None,
}
}
fn configure_world_draw_ranges(
&self,
renderer: &mut VulkanSmokeRenderer,
aspect: f32,
) -> Result<(), String> {
if let Some(lod) = &self.terrain_lod {
lod.report(
self.view_camera.position,
horizontal_fov(&self.view_camera, aspect),
)?;
}
let approximate_portals = self
.world_range_indices
.iter()
.filter(|&&index| {
let flags = self.mesh.draw_ranges[index].batch_flags;
flags & 8 != 0
&& flags & 0x50 != 0x40
&& !self.type16_present_ranges.contains(&index)
})
.count();
eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)");
let randomized_lights = self
.control_point_light_slots
.iter()
.filter(|slot| {
[92..108, 116..120].into_iter().any(|range| {
slot.command.raw_body.get(range).is_some_and(|bytes| {
bytes
.chunks_exact(4)
.any(|word| f32::from_le_bytes(word.try_into().unwrap()) != 0.0)
})
})
})
.count();
eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)");
eprintln!("render_coverage control_op2_supported={} control_op2_unsupported={} (unsupported timing, backend mode or parameters are skipped)", self.control_sound_slots.len(), self.unsupported_control_sounds);
eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len());
for &range_index in &self.world_range_indices {
let range = self
.mesh
.draw_ranges
.get(range_index)
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
let alpha = self
.materials
.get(usize::from(range.material_index))
.map_or(1.0, |material| material.diffuse_alpha);
let alpha_override = native_msh_diffuse_alpha_override(
&self.mesh,
range_index,
self.type16_present_ranges.contains(&range_index),
self.view_camera.position,
horizontal_fov(&self.view_camera, aspect),
)?;
let effective_alpha = alpha_override.unwrap_or(alpha);
renderer
.set_draw_range_diffuse_alpha_override(range_index, alpha_override)
.map_err(|error| {
format!("configure world draw range {range_index} alpha: {error}")
})?;
let transparent = native_world_transparent(range, effective_alpha);
renderer
.set_draw_range_transparency(range_index, transparent)
.map_err(|error| format!("configure world draw range {range_index}: {error}"))?;
let category = self
.materials
.get(usize::from(range.material_index))
.ok_or_else(|| "world draw range has no material selector".to_string())?
.pipeline_category;
renderer
.set_draw_range_material_category(range_index, category)
.map_err(|error| {
format!("configure world material category {range_index}: {error}")
})?;
let count = world_draw_range_index_count(
&self.mesh,
range,
range.index_count,
effective_alpha,
self.view_camera.position,
self.native_handedness_by_range.get(&range_index).copied(),
);
let count = if let Some(lod) = &self.terrain_lod {
lod.index_count(
range_index,
count,
self.view_camera.position,
horizontal_fov(&self.view_camera, aspect),
)?
} else {
count
};
renderer
.set_draw_range_index_count(range_index, count)
.map_err(|error| {
format!("configure world draw range {range_index} count: {error}")
})?;
}
Ok(())
}
fn configure_ambient_effect_draw_ranges(
&self,
renderer: &mut VulkanSmokeRenderer,
) -> Result<(), String> {
for slot in &self.ambient_effect_slots {
for (effect_range, _) in ambient_effect_ranges(slot) {
let range = self
.mesh
.draw_ranges
.get(effect_range.range_index)
.ok_or_else(|| "ambient FX draw range index is out of bounds".to_string())?;
let category = self
.materials
.get(usize::from(range.material_index))
.ok_or_else(|| "ambient FX draw range has no MAT0 selector".to_string())?
.pipeline_category;
renderer
.set_draw_range_transparency(effect_range.range_index, true)
.map_err(|error| {
format!(
"configure ambient FX draw range {}: {error}",
effect_range.range_index
)
})?;
renderer
.set_draw_range_material_category(effect_range.range_index, category)
.map_err(|error| format!("configure ambient FX material category: {error}"))?;
}
}
for (range_index, sort_key) in ambient_effect_draw_sort_keys(
&self.mesh,
&self.ambient_effect_slots,
self.view_camera.position,
)? {
renderer
.set_draw_range_sort_key(range_index, sort_key)
.map_err(|error| {
format!("configure ambient FX draw range {range_index} sort key: {error}")
})?;
}
Ok(())
}
fn update_world_transparency(
mesh: &VulkanStaticMesh,
materials: &[VulkanStaticMaterial],
world_range_indices: &[usize],
type16_present_ranges: &HashSet<usize>,
world_range_index_counts: &HashMap<usize, u32>,
native_handedness_by_range: &HashMap<usize, i8>,
terrain_lod: Option<&terrain_lod::TerrainLodDraws>,
eye_world: [f32; 3],
horizontal_fov: f32,
active_materials: &HashMap<usize, ActiveMaterialState>,
renderer: &mut VulkanSmokeRenderer,
) -> Result<(), String> {
for &range_index in world_range_indices {
let range = mesh
.draw_ranges
.get(range_index)
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
let state = active_materials.get(&range_index);
let material_alpha = state.map_or_else(
|| {
materials
.get(usize::from(range.material_index))
.map_or(1.0, |material| material.diffuse_alpha)
},
|state| state.diffuse_alpha,
);
let alpha_override = native_msh_diffuse_alpha_override(
mesh,
range_index,
type16_present_ranges.contains(&range_index),
eye_world,
horizontal_fov,
)?;
let alpha = alpha_override.unwrap_or(material_alpha);
renderer
.set_draw_range_diffuse_alpha_override(range_index, alpha_override)
.map_err(|error| format!("update world draw range {range_index} alpha: {error}"))?;
renderer
.set_draw_range_transparency(range_index, native_world_transparent(range, alpha))
.map_err(|error| format!("update world draw range {range_index}: {error}"))?;
let original_index_count = *world_range_index_counts
.get(&range_index)
.ok_or_else(|| "world draw range count is missing".to_string())?;
let count = world_draw_range_index_count(
mesh,
range,
original_index_count,
alpha,
eye_world,
native_handedness_by_range.get(&range_index).copied(),
);
let count = if let Some(lod) = terrain_lod {
lod.index_count(range_index, count, eye_world, horizontal_fov)?
} else {
count
};
renderer
.set_draw_range_index_count(range_index, count)
.map_err(|error| format!("update world draw range {range_index} count: {error}"))?;
}
Ok(())
}
fn world_draw_sort_keys(&self, camera_position: [f32; 3]) -> Result<Vec<(usize, f32)>, String> {
let mut keys = Vec::with_capacity(self.world_range_indices.len());
for &range_index in &self.world_range_indices {
let sort_key = first_indexed_vertex_distance(&self.mesh, range_index, camera_position)?;
keys.push((range_index, sort_key));
}
Ok(keys)
}
fn update_free_camera(&mut self, delta_seconds: f32, viewport: [f32; 2]) -> Result<(), String> {
let Some(mut free_camera) = self.free_camera else {
return Ok(());
};
let aspect = viewport[0] / viewport[1].max(1.0);
let safe_position = free_camera.position;
free_camera.advance(&self.pressed_keys, delta_seconds.clamp(0.0, 0.1));
free_camera.keep_above_terrain(&self.terrain)?;
if free_camera.position != safe_position
&& self.building_segment_blocked([safe_position, free_camera.position])?
{
free_camera.position = safe_position;
}
let view_camera = free_camera.preview_frame(aspect);
let camera = view_camera.vulkan_camera();
self.free_camera = Some(free_camera);
self.view_camera = view_camera;
self.camera = camera;
Ok(())
}
fn building_segment_blocked(&self, world_segment: [[f32; 3]; 2]) -> Result<bool, String> {
for binding in &self.building_collision_bindings {
if binding.selected_nodes.is_empty() {
continue;
}
let pose_buffer = self
.component_pose_buffers
.get(binding.object_index)
.and_then(|components| components.get(binding.component_index))
.and_then(Option::as_ref);
let mount_pose = self
.component_mount_poses
.get(binding.object_index)
.and_then(|components| components.get(binding.component_index))
.copied()
.flatten();
let nodes = building_collision_nodes(binding, pose_buffer, mount_pose)?;
if query_building_segment_world(&binding.model, &nodes, world_segment)
.map_err(|error| {
format!(
"query building collision object {} component {} model {}: {error}",
binding.object_index, binding.component_index, binding.model_source
)
})?
.is_some()
{
return Ok(true);
}
}
Ok(false)
}
fn sample_frame_clock(&mut self) -> FrameClockSample {
if let Some(target_ms) = self.control_clock_target_ms {
let time_ms =
control_clock_ms_for_frame(target_ms, self.frames_presented, self.target_frames);
self.simulation_elapsed = Duration::from_millis(u64::from(time_ms));
self.simulation_time_ms = time_ms;
return FrameClockSample {
id: SceneSampleId::Replay(self.frames_presented),
time_ms,
delta_seconds: replay_environment_delta_seconds(
self.last_environment_control_clock_ms,
time_ms,
),
};
}
self.sample_live_frame_clock_at(Instant::now())
}
fn pending_or_sample_frame_clock(&mut self) -> FrameClockSample {
match self.pending_scene_update.as_ref() {
Some(pending) => pending.clock,
None => self.sample_frame_clock(),
}
}
fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample {
let elapsed = now.saturating_duration_since(self.simulation_last_tick);
self.simulation_last_tick = now;
self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed);
self.simulation_time_ms = self.simulation_elapsed.as_millis() as u32;
let sample_id = SceneSampleId::Live(self.simulation_update_generation);
self.simulation_update_generation = self.simulation_update_generation.saturating_add(1);
FrameClockSample {
id: sample_id,
time_ms: self.simulation_time_ms,
delta_seconds: elapsed.as_secs_f32(),
}
}
fn pause_live_clock_at(&mut self, now: Instant) {
self.live_clock_paused_for_zero_extent = true;
self.simulation_last_tick = now;
}
fn resume_live_clock_at(&mut self, now: Instant) {
if self.live_clock_paused_for_zero_extent {
self.simulation_last_tick = now;
self.live_clock_paused_for_zero_extent = false;
}
}
fn update_shadow(
mesh: &mut VulkanStaticMesh,
dynamic_vertex_start: usize,
shadow_cache: &mut ShadowPageCache,
shadow_casters: &[ShadowCaster],
shadow_receivers: &[ShadowTriangle],
shadow_range: EnvironmentGpuRange,
shadow_base_vertex: usize,
shadow_base_index: usize,
shadow_material_index: usize,
uploaded_shadow_atlas: &mut VulkanStaticTexture,
renderer: &mut VulkanSmokeRenderer,
control_point_lights: &[(VulkanPointLight, u32)],
sky_frame: Option<&SkyFrame<'_>>,
environment_frame: Option<&EnvironmentFrame>,
shadow_camera: Option<ShadowCamera>,
) -> Result<ShadowFrameEvidence, String> {
let lights = shadow_lights(sky_frame, environment_frame, renderer, control_point_lights);
let visible_casters = native_visible_shadow_casters(
shadow_casters,
renderer.frame_uniforms().clip_from_world,
);
let frame = shadow_cache
.build(ShadowScene {
lights: &lights,
casters: &visible_casters,
receivers: shadow_receivers,
fade: 1.0,
smooth_passes: 1,
camera: shadow_camera,
lod: ShadowLodSettings::default(),
})
.map_err(|error| format!("build projected shadow frame: {error}"))?;
let geometry = append_shadow_frame_geometry(
mesh,
shadow_range,
shadow_base_vertex,
shadow_base_index,
&frame,
)?;
let atlas = shadow_atlas_texture_from_frame(&frame);
if atlas != *uploaded_shadow_atlas {
renderer
.update_material_texture(shadow_material_index, 0, &atlas)
.map_err(|error| format!("upload projected shadow atlas: {error}"))?;
*uploaded_shadow_atlas = atlas;
}
renderer
.update_mesh_geometry(
&mesh.vertices,
&mesh.indices,
&[geometry],
dynamic_vertex_start,
shadow_base_index,
)
.map_err(|error| format!("upload environment and shadow geometry: {error}"))?;
Ok(ShadowFrameEvidence {
visible_casters: visible_casters.len(),
projected_indices: frame.indices.len(),
})
}
fn prepare_scene_update(
&mut self,
clock: FrameClockSample,
drawable_viewport: [f32; 2],
) -> Result<(), String> {
let viewport = self.captured_viewport.map_or_else(
|| [drawable_viewport[0].max(1.0), drawable_viewport[1].max(1.0)],
|viewport| [viewport.extent[0] as f32, viewport.extent[1] as f32],
);
let aspect = viewport[0] / viewport[1].max(1.0);
if let Some((pending_id, pending_viewport)) = self
.pending_scene_update
.as_ref()
.map(|pending| (pending.clock.id, pending.viewport))
{
if pending_id == clock.id {
if pending_viewport != viewport {
self.refresh_pending_scene_viewport(viewport, aspect)?;
}
return Ok(());
}
}
let control_clock_ms = clock.time_ms;
let effect_elapsed_seconds = control_clock_ms as f32 / 1000.0;
// CTLD's validated NDP defaults seed the native query gate. This
// preview does not process mission Action1 updates, so it retains that
// initial provider value for the capture.
update_control_animation_pose_buffers(
&mut self.control_animation_instances,
&mut self.component_pose_buffers,
control_clock_ms,
)?;
update_dynamic_component_geometry(
&self.dynamic_component_geometry,
&self.component_pose_buffers,
&mut self.component_mount_poses,
&mut self.mesh,
)?;
self.update_free_camera(clock.delta_seconds, viewport)?;
refresh_dynamic_shadow_casters(
&mut self.shadow_casters,
&self.dynamic_shadow_casters,
&self.mesh,
&self.component_pose_buffers,
&self.component_mount_poses,
)?;
for slot in &mut self.ambient_effect_slots {
if slot.owner_component_is_mounted {
slot.mount_pose = self
.component_mount_poses
.get(slot.owner_object_index)
.and_then(|components| components.get(slot.owner_component_index))
.copied()
.flatten();
}
}
let control_effect_random_samples = advance_control_effect_random_states(
&mut self.control_effect_random_states,
&self.ambient_effect_slots,
&self.control_point_light_slots,
&self.control_sound_slots,
clock.id,
control_clock_ms,
effect_elapsed_seconds,
);
let control_point_lights = update_control_point_lights(
&mut self.control_point_light_slots,
&self.component_pose_buffers,
&self.component_mount_poses,
effect_elapsed_seconds,
&control_effect_random_samples,
std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_some()
&& !self.control_point_lights_diagnostic_reported
&& self
.control_clock_target_ms
.is_none_or(|target_ms| control_clock_ms >= target_ms),
);
let control_vulkan_lights = ordinary_control_point_lights(&control_point_lights);
let effect_updates = update_ambient_effect_geometry(
&mut self.ambient_effect_slots,
&self.component_pose_buffers,
&mut self.mesh,
&mut self.material_animations,
effect_elapsed_seconds,
&control_effect_random_samples,
self.view_camera.position,
)?;
let world_sort_keys = self.world_draw_sort_keys(self.view_camera.position)?;
let ambient_sort_keys = ambient_effect_draw_sort_keys(
&self.mesh,
&self.ambient_effect_slots,
self.view_camera.position,
)?;
let camera = self.view_camera;
let material_update = sample_material_animations(
&mut self.material_animations,
&self.materials,
clock.time_ms,
)?;
let active_materials = &material_update.active_materials;
apply_ambient_effect_material_tints(
&self.ambient_effect_slots,
&mut self.mesh,
&effect_updates,
&material_update.active_materials,
)?;
let diagnostic_mode = std::env::var("FPARKAN_AMBIENT_FX_DIAGNOSTIC").ok();
if diagnostic_mode.is_some() {
let generator_capture_is_final = diagnostic_mode.as_deref() == Some("generator")
&& self
.control_clock_target_ms
.is_none_or(|target_ms| control_clock_ms >= target_ms);
for update in &effect_updates {
let Some((slot, range)) = self.ambient_effect_slots.iter().find_map(|slot| {
ambient_effect_range_for(slot, update.range_index).map(|range| (slot, range))
}) else {
continue;
};
if generator_capture_is_final
&& slot.kind == AmbientEffectKind::GenerBall
&& slot.control_source == "fortif.rlb/fr_l_gener.ctl"
{
let random_sample = control_effect_random_samples
.get(&slot.random_instance_key)
.copied()
.unwrap_or_default();
let phase = ambient_effect_phase(
slot.time_mode,
slot.header_flags,
effect_elapsed_seconds,
slot.duration_seconds,
)
.map(|phase| {
native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)
});
let evaluated = phase.and_then(|phase| {
evaluate_f_gener_ball(
&slot.command,
slot.command_index,
&slot.world_from_effect,
phase,
effect_elapsed_seconds,
self.view_camera.position,
)
});
let expected_vertices = evaluated.as_ref().map(|frame| frame.vertices);
let expected_center =
expected_vertices.map(|vertices| ambient_effect_quad_center(&vertices));
let expected_size =
expected_vertices.map(|vertices| ambient_effect_quad_size(&vertices));
let expected_screen = expected_vertices.and_then(|vertices| {
ambient_effect_projected_bounds(
&vertices,
self.view_camera.clip_from_world,
self.captured_viewport
.map_or([1280, 1024], |viewport| viewport.extent),
)
});
let actual_vertices = self
.mesh
.vertices
.get(range.vertex_start..range.vertex_start.saturating_add(4));
let actual_center: Option<[f32; 3]> = actual_vertices.map(|vertices| {
std::array::from_fn(|axis| {
vertices
.iter()
.map(|vertex| vertex.position[axis])
.sum::<f32>()
/ 4.0
})
});
let actual_rgb = actual_vertices
.and_then(|vertices| vertices.first())
.map(|vertex| vertex.color);
let actual_alpha = actual_vertices
.and_then(|vertices| vertices.first())
.map(|vertex| vertex.overlay_alpha);
let material = active_materials.get(&update.range_index);
eprintln!(
"ambient_fx_generator_diagnostic elapsed={effect_elapsed_seconds:.3} clock_ms={control_clock_ms} owner_object={} owner_component={} source={} table={} row={} points={:?} opcode={:?} command={} range={} submitted_indices={} effect_phase={phase:?} evaluator_active={:?} fx_mat_phase={:?} fx_alpha={:?} expected_center={expected_center:?} expected_size={expected_size:?} expected_screen={expected_screen:?} actual_center={actual_center:?} actual_rgb={actual_rgb:?} actual_alpha={actual_alpha:?} mat_selector={:?} mat_phase_index={:?} sampled_mat_rgb={:?}",
slot.owner_object_index,
slot.owner_component_index,
slot.control_source,
slot.control_table_index,
slot.control_row_index,
slot.control_point_indices,
slot.command.opcode,
slot.command_index,
update.range_index,
update.index_count,
evaluated.as_ref().map(|frame| frame.active),
evaluated.as_ref().map(|frame| frame.material_phase),
evaluated.as_ref().map(|frame| frame.color[3]),
material.map(|material| material.material_index),
material.map(|material| material.phase_index),
material.map(|material| material.additive_rgb),
);
continue;
}
if diagnostic_mode.as_deref() == Some("generator")
|| slot.control_source != "animals.rlb/a_a_l3.ctl"
|| update.index_count == 0
{
continue;
}
let start = range.vertex_start;
let vertices = self
.mesh
.vertices
.get(start..start.saturating_add(range.vertex_capacity))
.unwrap_or_default();
let center = if vertices.is_empty() {
[0.0; 3]
} else {
std::array::from_fn(|axis| {
vertices
.iter()
.map(|vertex| vertex.position[axis])
.sum::<f32>()
/ vertices.len() as f32
})
};
let color = vertices
.first()
.map(|vertex| vertex.color)
.unwrap_or([0.0; 3]);
let alpha = vertices
.first()
.map(|vertex| vertex.overlay_alpha)
.unwrap_or(0.0);
eprintln!(
"ambient_fx_diagnostic elapsed={effect_elapsed_seconds:.3} source={} table={} row={} points={:?} owners={:?} parent_translation={:?} opcode={:?} command={} range={} active_indices={} center={center:?} command_rgb={:?} sampled_mat_rgb={color:?} alpha={alpha:.3}",
slot.control_source,
slot.control_table_index,
slot.control_row_index,
slot.control_point_indices,
slot.control_point_owners,
[slot.world_from_effect[0][3], slot.world_from_effect[1][3], slot.world_from_effect[2][3]],
slot.command.opcode,
slot.command_index,
update.range_index,
update.index_count,
update.command_rgb,
);
}
}
if let Some(audio) = self.audio.as_mut() {
audio
.update_listener(camera.position, camera.forward, camera.up)
.map_err(|error| format!("update audio listener: {error}"))?;
for (id, sound) in self.control_sound_slots.iter_mut().enumerate() {
let slot = &mut sound.effect;
if slot.owner_component_is_mounted {
slot.mount_pose = self
.component_mount_poses
.get(slot.owner_object_index)
.and_then(|components| components.get(slot.owner_component_index))
.copied()
.flatten();
}
let random = control_effect_random_samples
.get(&slot.random_instance_key)
.copied()
.unwrap_or_default();
let Some(matrix) = control_effect_world_from_component_pose_with_random_shift(
slot,
&self.component_pose_buffers,
random.accumulated_shift,
) else {
continue;
};
let Some(phase) = ambient_effect_phase(
slot.time_mode,
slot.header_flags,
effect_elapsed_seconds,
slot.duration_seconds,
)
.map(|phase| native_command_phase(slot.header_flags, phase, random.phase_jitter)) else {
continue;
};
if let Some(event) = sound.command.update(
&mut sound.state,
phase,
matrix,
audio.control_sound_is_playing(id),
) {
audio
.handle_control_sound(id, &event)
.map_err(|error| format!("play Control sound {id}: {error}"))?;
}
}
}
let environment = if let Some(environment) = self.environment.as_mut() {
Some(environment.update_cpu(
clock.delta_seconds,
clock.time_ms,
&camera,
aspect,
viewport,
self.audio.as_mut(),
)?)
} else {
None
};
self.environment_primitive_count = environment
.as_ref()
.map_or(0, |frame| frame.environment_frame.primitives.len());
if matches!(clock.id, SceneSampleId::Replay(_)) {
self.last_environment_control_clock_ms = control_clock_ms;
}
let environment_material_update =
if let Some(environment_gpu) = self.environment_gpu.as_mut() {
Some(sample_material_animations(
&mut environment_gpu.material_animations,
&self.materials,
clock.time_ms,
)?)
} else {
None
};
let environment_point_light_count = environment.as_ref().map_or(0, |frame| {
frame
.environment_frame
.point_lights
.iter()
.filter(|light| light.active)
.count()
});
report_control_point_lights_once(
&mut self.control_point_lights_diagnostic_reported,
control_clock_ms,
self.control_clock_target_ms,
environment_point_light_count,
&control_point_lights,
&control_vulkan_lights,
);
self.pending_scene_update = Some(SceneFrameUpdate {
clock,
camera,
viewport,
aspect,
control_point_lights,
control_vulkan_lights,
effect_updates,
world_sort_keys,
ambient_sort_keys,
material_update,
environment_material_update,
environment,
});
Ok(())
}
fn refresh_pending_scene_viewport(
&mut self,
viewport: [f32; 2],
aspect: f32,
) -> Result<(), String> {
let Some((previous_camera, previous_aspect)) = self
.pending_scene_update
.as_ref()
.map(|pending| (pending.camera, pending.aspect))
else {
return Ok(());
};
let camera = previous_camera.with_aspect(previous_aspect, aspect);
self.view_camera = camera;
self.camera = camera.vulkan_camera();
let environment_camera = environment_camera_for_preview(&camera, aspect, viewport);
if let Some(environment) = self
.pending_scene_update
.as_mut()
.and_then(|pending| pending.environment.as_mut())
{
environment
.environment_frame
.reproject_for_camera(environment_camera);
}
let pending = self
.pending_scene_update
.as_mut()
.expect("pending scene update was checked above");
pending.camera = camera;
pending.viewport = viewport;
pending.aspect = aspect;
Ok(())
}
fn render_scene_update(&mut self) -> Result<(), String> {
let Some(frame) = self.pending_scene_update.as_ref() else {
return Ok(());
};
let Some(renderer) = self.renderer.as_mut() else {
return Ok(());
};
renderer
.set_camera(frame.camera.vulkan_camera())
.map_err(|error| format!("apply sampled scene camera: {error}"))?;
apply_material_frame_update(
&frame.material_update,
&mut self.material_animations,
renderer,
)?;
for update in &frame.effect_updates {
renderer
.set_draw_range_index_count(update.range_index, update.index_count)
.map_err(|error| {
format!(
"update ambient FX draw range {}: {error}",
update.range_index
)
})?;
}
for &(range_index, sort_key) in &frame.world_sort_keys {
renderer
.set_draw_range_sort_key(range_index, sort_key)
.map_err(|error| {
format!("update dynamic world draw range {range_index} sort key: {error}")
})?;
}
for &(range_index, sort_key) in &frame.ambient_sort_keys {
renderer
.set_draw_range_sort_key(range_index, sort_key)
.map_err(|error| {
format!("update ambient FX draw range {range_index} sort key: {error}")
})?;
}
Self::update_world_transparency(
&self.mesh,
&self.materials,
&self.world_range_indices,
&self.type16_present_ranges,
&self.world_range_index_counts,
&self.native_handedness_by_range,
self.terrain_lod.as_ref(),
frame.camera.position,
horizontal_fov(&frame.camera, frame.aspect),
&frame.material_update.active_materials,
renderer,
)?;
let shadow_camera = ShadowCamera {
position: frame.camera.position,
viewport_width: frame.viewport[0],
horizontal_fov: horizontal_fov(&frame.camera, frame.aspect),
};
if let Some(sampled_environment) = frame.environment.as_ref() {
let environment = self
.environment
.as_ref()
.ok_or_else(|| "sampled atmosphere lost its owner".to_string())?;
let sky_frame = SkyFrame::from_atmosphere(
&sampled_environment.atmosphere,
&environment.materials,
sampled_environment.absolute_time_seconds,
);
let environment_frame = &sampled_environment.environment_frame;
let mut uniforms = renderer.frame_uniforms();
uniforms.clip_from_world = frame.camera.clip_from_world;
for (target, source) in uniforms
.directional_lights
.iter_mut()
.zip(sky_frame.directional_lights)
{
target.direction = source.direction;
target.rgb = source.color;
target.coefficients = [0.0, 0.0, 1.0];
target.active = source.active;
}
uniforms.lighting_floor = sky_frame.lighting_floor;
let fog_rgb = sky_frame
.screen_gradient(frame.camera.native_sky_heading(), [0.0; 3])
.color
.rgba();
uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]];
uniforms.fog_start = sky_frame.fog.start.max(0.0);
uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start);
uniforms.camera_position = frame.camera.position;
renderer
.set_frame_uniforms(uniforms)
.map_err(|error| format!("update sky frame uniforms: {error}"))?;
let mut uniforms = renderer.frame_uniforms();
uniforms.point_light = environment_frame
.point_lights
.iter()
.find(|light| light.active)
.map(|light| VulkanPointLight {
position: light.position,
rgb: [light.color[0], light.color[1], light.color[2]],
range: light.range,
coefficients: light.attenuation,
active: true,
})
.unwrap_or_default();
renderer
.set_frame_uniforms(uniforms)
.map_err(|error| format!("update environment point-light uniforms: {error}"))?;
let mut point_lights = environment_frame
.point_lights
.iter()
.filter(|light| light.active)
.map(|light| VulkanPointLight {
position: light.position,
rgb: [light.color[0], light.color[1], light.color[2]],
range: light.range,
coefficients: light.attenuation,
active: true,
})
.collect::<Vec<_>>();
point_lights.extend(frame.control_vulkan_lights.iter().copied());
renderer
.set_point_lights(&point_lights)
.map_err(|error| format!("update complete scene point-light set: {error}"))?;
if let Some(environment_gpu) = self.environment_gpu.as_mut() {
let material_update = frame
.environment_material_update
.as_ref()
.ok_or_else(|| "sampled environment material state is missing".to_string())?;
apply_material_frame_update(
material_update,
&mut environment_gpu.material_animations,
renderer,
)?;
environment_gpu.update(
&mut self.mesh,
renderer,
material_update,
&sky_frame,
&sampled_environment.sky_mesh,
environment_frame,
&frame.camera,
frame.viewport,
&self.terrain,
&self.sun_occlusion_range_indices,
)?;
}
let shadow_material = self
.materials
.get_mut(self.shadow_material_index)
.ok_or_else(|| "projected shadow material is out of bounds".to_string())?;
self.shadow_last_evidence = Self::update_shadow(
&mut self.mesh,
self.dynamic_vertex_start,
&mut self.shadow_cache,
&self.shadow_casters,
&self.shadow_receivers,
self.shadow_range,
self.shadow_base_vertex,
self.shadow_base_index,
self.shadow_material_index,
&mut shadow_material.texture,
renderer,
&frame.control_point_lights,
Some(&sky_frame),
Some(environment_frame),
Some(shadow_camera),
)?;
} else {
renderer
.set_point_lights(&frame.control_vulkan_lights)
.map_err(|error| format!("update complete scene point-light set: {error}"))?;
let shadow_material = self
.materials
.get_mut(self.shadow_material_index)
.ok_or_else(|| "projected shadow material is out of bounds".to_string())?;
self.shadow_last_evidence = Self::update_shadow(
&mut self.mesh,
self.dynamic_vertex_start,
&mut self.shadow_cache,
&self.shadow_casters,
&self.shadow_receivers,
self.shadow_range,
self.shadow_base_vertex,
self.shadow_base_index,
self.shadow_material_index,
&mut shadow_material.texture,
renderer,
&frame.control_point_lights,
None,
None,
Some(shadow_camera),
)?;
}
Ok(())
}
fn record_presented_scene_frame(&mut self) -> u64 {
let frame_index = self.frames_presented;
self.frames_presented = self.frames_presented.saturating_add(1);
self.pending_scene_update = None;
frame_index
}
fn set_mouse_look(&mut self, active: bool) {
self.mouse_look = active;
if let Some(window) = self.window.as_ref() {
if active {
// Relative device motion keeps the camera usable at the edge
// of the window. Locked grab is unavailable on a few window
// backends, so confined grab is a safe fallback.
if window.set_cursor_grab(CursorGrabMode::Locked).is_err() {
let _ = window.set_cursor_grab(CursorGrabMode::Confined);
}
} else {
let _ = window.set_cursor_grab(CursorGrabMode::None);
}
window.set_cursor_visible(!active);
}
}
fn schedule_next_redraw(&self) {
if let Some(window) = self.window.as_ref() {
window.request_redraw();
}
}
fn complete(&mut self, event_loop: &ActiveEventLoop) {
if let Some(audio) = self.audio.as_mut() {
audio.shutdown();
}
let Some(renderer) = self.renderer.take() else {
self.error = Some("native Vulkan renderer was not initialized".to_string());
event_loop.exit();
return;
};
let report = match renderer.shutdown() {
Ok(report) => report,
Err(err) => {
self.error = Some(err.to_string());
event_loop.exit();
return;
}
};
self.window.take();
if self.enable_validation
&& (report.validation.warning_count != 0 || report.validation.error_count != 0)
{
self.error = Some(format!(
"native Vulkan validation must stay clean (warnings={}, errors={}, vuids={:?})",
report.validation.warning_count,
report.validation.error_count,
report.validation.vuids,
));
event_loop.exit();
return;
}
let validation_status = if self.enable_validation {
"clean"
} else {
"disabled"
};
let readback_path = match (&self.readback_out, &report.readback_artifact) {
(Some(path), Some(artifact)) => {
if let Some(parent) = path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
self.error = Some(format!("{}: {err}", parent.display()));
event_loop.exit();
return;
}
}
if let Err(err) = std::fs::write(path, &artifact.bytes) {
self.error = Some(format!("{}: {err}", path.display()));
event_loop.exit();
return;
}
Some(path.display().to_string())
}
(Some(_), None) => {
self.error =
Some("native Vulkan renderer produced no synchronized readback".to_string());
event_loop.exit();
return;
}
(None, _) => None,
};
let readback_format = report
.readback_artifact
.as_ref()
.map_or(report.renderer_report.swapchain_image_format, |artifact| {
artifact.format
});
if let Some(control_clock_ms) = self.control_clock_target_ms {
emit_control_animation_capture_diagnostics(
&self.control_animation_instances,
&self.component_pose_buffers,
control_clock_ms,
);
}
let captured_crop = self.captured_viewport.map_or_else(
|| "none".to_string(),
|viewport| {
format!(
"{:?} ({}x{})",
viewport.rect, viewport.extent[0], viewport.extent[1]
)
},
);
eprintln!(
"render_geometry_upload vertex_bytes={} index_bytes={} full_upload_bytes={}",
report.renderer_report.mesh_vertex_upload_bytes,
report.renderer_report.mesh_index_upload_bytes,
report.renderer_report.mesh_full_upload_bytes,
);
eprintln!(
"render_texture_upload slots={} initial_images={} detached_images={}",
self.materials.len() * 4,
report.renderer_report.initial_texture_image_count,
report.renderer_report.texture_detach_count,
);
eprintln!(
"render_pipeline_cache pipelines_created={}",
report.renderer_report.pipeline_creation_count
);
self.output = Some(format!(
"rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, control_clock_ms={}, control_clock_mode={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, readback_sequence={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}",
self.mission,
self.frames_presented,
self.object_count,
self.mesh_components,
self.terrain_components,
self.camera_mode,
captured_crop,
self.control_clock_target_ms
.map_or_else(|| "realtime".to_string(), |clock| clock.to_string()),
control_clock_mode_label(self.control_clock_target_ms),
self.materials.len(),
self.environment_primitive_count,
self.shadow_last_evidence.visible_casters,
self.shadow_last_evidence.projected_indices,
report.swapchain_recreate_count,
validation_status,
report.renderer_report.swapchain_image_format,
readback_format,
readback_path.as_deref().unwrap_or("none"),
self.readback_sequence_out
.as_deref()
.map_or_else(|| "none".to_string(), |path| path.display().to_string()),
elapsed_ms(self.startup_timings.mission_assets),
elapsed_ms(self.startup_timings.terrain_materials),
elapsed_ms(self.startup_timings.sky),
elapsed_ms(self.startup_timings.gpu_initialization),
));
event_loop.exit();
}
fn finish(self) -> Result<String, String> {
self.output.ok_or_else(|| {
self.error.unwrap_or_else(|| {
"native Vulkan mode exited before producing a report".to_string()
})
})
}
}
impl ApplicationHandler for StaticVulkanApp {
fn device_event(
&mut self,
_event_loop: &ActiveEventLoop,
_device_id: DeviceId,
event: DeviceEvent,
) {
if self.mouse_look {
if let DeviceEvent::MouseMotion { delta } = event {
if let Some(camera) = self.free_camera.as_mut() {
camera.look_delta(delta.0, delta.1);
}
}
}
}
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let plan = match WinitWindowPlan::smoke().validate() {
Ok(plan) => plan,
Err(err) => {
self.error = Some(err.to_string());
event_loop.exit();
return;
}
};
let (width, height) = self
.captured_viewport
.map(|viewport| (viewport.extent[0], viewport.extent[1]))
.unwrap_or((plan.width, plan.height));
let attributes = Window::default_attributes()
.with_title("FParkan mission")
.with_inner_size(WinitPhysicalSize::new(width, height))
// The captured clip matrix is exact for the recorded viewport
// aspect. Keep that drawable fixed; compare it with the same
// (left, top, right, bottom) crop in the original capture.
.with_resizable(self.captured_viewport.is_none());
let window = match event_loop.create_window(attributes) {
Ok(window) => window,
Err(err) => {
self.error = Some(format!("winit window: {err}"));
event_loop.exit();
return;
}
};
let size = window.inner_size();
if let Some(captured_viewport) = self.captured_viewport {
if let Err(error) =
validate_captured_drawable_extent(captured_viewport, [size.width, size.height])
{
self.error = Some(error);
event_loop.exit();
return;
}
}
let Some(native_handles) = window_native_handles(&window) else {
self.error = Some("winit window does not expose native handles".to_string());
event_loop.exit();
return;
};
let gpu_initialization_started = Instant::now();
if let Err(error) = configure_world_material_categories(
&mut self.mesh,
&self.materials,
&self.world_range_indices,
) {
self.error = Some(error);
event_loop.exit();
return;
}
let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo {
application_name: "fparkan-game".to_string(),
native_handles,
drawable_extent: (size.width.max(1), size.height.max(1)),
render_request: WinitWindow::default_render_request(),
enable_validation: self.enable_validation,
mesh: self.mesh.clone(),
camera: self.camera,
materials: self.materials.clone(),
bootstrap_progress: None,
}) {
Ok(renderer) => renderer,
Err(error) => {
self.error = Some(error.to_string());
event_loop.exit();
return;
}
};
let background_ranges = self.environment_gpu.as_ref().map_or_else(
Vec::new,
EnvironmentGpuScene::transparent_background_range_indices,
);
if let Err(error) = renderer.set_world_transparent_background_ranges(&background_ranges) {
self.error = Some(format!("configure world background ranges: {error}"));
event_loop.exit();
return;
}
if let Err(error) = self.configure_world_draw_ranges(
&mut renderer,
size.width.max(1) as f32 / size.height.max(1) as f32,
) {
self.error = Some(error);
event_loop.exit();
return;
}
if let Err(error) = self.configure_ambient_effect_draw_ranges(&mut renderer) {
self.error = Some(error);
event_loop.exit();
return;
}
match self.world_draw_sort_keys(self.view_camera.position) {
Ok(sort_keys) => {
for (range_index, sort_key) in sort_keys {
if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key) {
self.error = Some(format!(
"update world draw range {range_index} sort key: {error}"
));
event_loop.exit();
return;
}
}
}
Err(error) => {
self.error = Some(error);
event_loop.exit();
return;
}
}
self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed();
renderer.set_readback_enabled(
self.readback_out.is_some() || self.readback_sequence_out.is_some(),
);
self.window_id = Some(window.id());
self.window = Some(window);
self.renderer = Some(renderer);
self.simulation_last_tick = Instant::now();
self.live_clock_paused_for_zero_extent = false;
self.pending_scene_update = None;
let drawable_size = self
.window
.as_ref()
.map(Window::inner_size)
.unwrap_or_default();
if let Err(error) = self.update_free_camera(
0.0,
[drawable_size.width as f32, drawable_size.height as f32],
) {
self.error = Some(error);
event_loop.exit();
return;
}
self.schedule_next_redraw();
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
if Some(window_id) != self.window_id {
return;
}
match event {
WindowEvent::CloseRequested => {
self.complete(event_loop);
}
WindowEvent::Focused(focused) => {
if !focused {
self.pressed_keys.clear();
self.set_mouse_look(false);
}
if let Some(audio) = self.audio.as_mut() {
audio.on_focus_changed(focused);
}
}
WindowEvent::KeyboardInput { event, .. } => {
if let PhysicalKey::Code(code) = event.physical_key {
if code == KeyCode::Escape && event.state == ElementState::Pressed {
if self.mouse_look {
self.set_mouse_look(false);
} else {
self.complete(event_loop);
}
} else if event.state == ElementState::Pressed {
self.pressed_keys.insert(code);
} else {
self.pressed_keys.remove(&code);
}
}
}
WindowEvent::MouseInput { state, button, .. } => {
if button == MouseButton::Right {
self.set_mouse_look(state == ElementState::Pressed);
}
}
WindowEvent::CursorMoved { .. } => {}
WindowEvent::Resized(size) => {
// Queued creation resize events can predate the current physical drawable.
let drawable_size = if self.captured_viewport.is_some() {
self.window.as_ref().map(Window::inner_size).unwrap_or(size)
} else {
size
};
if let Some(captured_viewport) = self.captured_viewport {
if drawable_size.width != 0 && drawable_size.height != 0 {
if let Err(error) = validate_captured_drawable_extent(
captured_viewport,
[drawable_size.width, drawable_size.height],
) {
self.error = Some(error);
event_loop.exit();
return;
}
}
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.request_resize((drawable_size.width, drawable_size.height));
}
if drawable_size.width == 0 || drawable_size.height == 0 {
event_loop.set_control_flow(ControlFlow::Wait);
self.pause_live_clock_at(Instant::now());
} else {
event_loop.set_control_flow(ControlFlow::Poll);
self.resume_live_clock_at(Instant::now());
// The pending simulation sample is reused; its view and
// viewport-dependent geometry refresh before the retry.
}
}
WindowEvent::RedrawRequested => {
if self.pending_scene_update.is_none() {
return;
}
if let Err(error) = self.render_scene_update() {
self.error = Some(error);
event_loop.exit();
return;
}
let diagnostic_clock_ms = self
.pending_scene_update
.as_ref()
.map_or(self.simulation_time_ms, |frame| frame.clock.time_ms);
let outcome = {
let Some(renderer) = self.renderer.as_mut() else {
self.error = Some("native Vulkan renderer was not initialized".to_string());
event_loop.exit();
return;
};
renderer.draw_frame()
};
match outcome {
Ok(VulkanSmokeFrameOutcome::Presented) => {
let frame_index = self.record_presented_scene_frame();
if let Some(directory) = self.readback_sequence_out.as_deref() {
let artifact = match self
.renderer
.as_mut()
.expect("renderer presented a frame")
.readback_last_submitted_frame()
{
Ok(Some(artifact)) => artifact,
Ok(None) => {
self.error = Some(
"native Vulkan renderer produced no synchronized sequence readback"
.to_string(),
);
event_loop.exit();
return;
}
Err(error) => {
self.error =
Some(format!("read back frame {frame_index}: {error}"));
event_loop.exit();
return;
}
};
if let Err(error) = write_readback_sequence_frame(
directory,
frame_index,
diagnostic_clock_ms,
&artifact,
) {
self.error = Some(error);
event_loop.exit();
return;
}
}
}
Ok(VulkanSmokeFrameOutcome::Recreated) => {}
Ok(VulkanSmokeFrameOutcome::ZeroExtent) => {
if self.window.as_ref().is_some_and(|window| {
let size = window.inner_size();
size.width == 0 || size.height == 0
}) {
event_loop.set_control_flow(ControlFlow::Wait);
self.pause_live_clock_at(Instant::now());
}
}
Err(err) => {
self.error = Some(err.to_string());
event_loop.exit();
return;
}
}
if self.target_frames != 0 && self.frames_presented >= self.target_frames {
self.complete(event_loop);
}
}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if self.output.is_some() || self.error.is_some() {
return;
}
let Some(window) = self.window.as_ref() else {
return;
};
let size = window.inner_size();
if size.width == 0 || size.height == 0 {
// Wait for a resize instead of spinning simulation updates while
// the window has no drawable surface.
event_loop.set_control_flow(ControlFlow::Wait);
self.pause_live_clock_at(Instant::now());
return;
}
event_loop.set_control_flow(ControlFlow::Poll);
self.resume_live_clock_at(Instant::now());
let clock = self.pending_or_sample_frame_clock();
if let Err(error) =
self.prepare_scene_update(clock, [size.width as f32, size.height as f32])
{
self.error = Some(error);
event_loop.exit();
return;
}
self.schedule_next_redraw();
}
}
#[derive(Clone, Debug, PartialEq)]
struct Args {
root: PathBuf,
mission: String,
frames: u64,
atmosphere_seconds: Option<f32>,
control_clock_ms: Option<u32>,
validation: bool,
readback_out: Option<PathBuf>,
readback_sequence_out: Option<PathBuf>,
preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>,
static_animation_frame: Option<u16>,
static_material_phase: Option<u16>,
}
impl Args {
fn parse(args: &[String]) -> Result<Self, String> {
let mut root = None;
let mut mission = None;
// Zero means keep the interactive preview open until the user closes
// it. A bounded `--frames N` remains available for smoke/readback
// runs and validation scripts.
let mut frames = 0;
let mut atmosphere_seconds = None;
let mut control_clock_ms = None;
let mut validation = false;
let mut readback_out = None;
let mut readback_sequence_out = None;
// A native static-Vulkan invocation is the usable mission preview, so
// its default scope covers every root. `PreviewRoots` is clamped by the
// runtime to the decoded mission length; an explicit --preview-roots N
// remains available for bounded diagnostic work.
let mut preview_roots = NonZeroUsize::MAX;
let mut legacy_camera_capture = None;
let mut static_animation_frame = None;
let mut static_material_phase = None;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--root" => {
root = Some(
iter.next()
.map(PathBuf::from)
.ok_or_else(|| "--root requires a path".to_string())?,
);
}
"--mission" => {
mission = Some(
iter.next()
.cloned()
.ok_or_else(|| "--mission requires a path".to_string())?,
);
}
"--frames" => {
frames = iter
.next()
.ok_or_else(|| "--frames requires a value".to_string())?
.parse()
.map_err(|_| "--frames must be an integer".to_string())?;
}
"--atmosphere-seconds" => {
let value = iter
.next()
.ok_or_else(|| "--atmosphere-seconds requires a value".to_string())?
.parse::<f32>()
.map_err(|_| "--atmosphere-seconds must be a number".to_string())?;
if !value.is_finite() || value < 0.0 {
return Err(
"--atmosphere-seconds must be finite and non-negative".to_string()
);
}
atmosphere_seconds = Some(value);
}
"--control-clock-ms" => {
control_clock_ms = Some(
iter.next()
.ok_or_else(|| "--control-clock-ms requires a value".to_string())?
.parse()
.map_err(|_| "--control-clock-ms must be a u32 integer".to_string())?,
);
}
"--validation" => {
validation = true;
}
"--readback-out" => {
readback_out = Some(
iter.next()
.map(PathBuf::from)
.ok_or_else(|| "--readback-out requires a path".to_string())?,
);
}
"--readback-sequence-out" => {
readback_sequence_out =
Some(iter.next().map(PathBuf::from).ok_or_else(|| {
"--readback-sequence-out requires a path".to_string()
})?);
}
"--preview-roots" => {
preview_roots = iter
.next()
.ok_or_else(|| "--preview-roots requires a value".to_string())?
.parse()
.map_err(|_| "--preview-roots must be a non-zero integer".to_string())?;
}
"--legacy-camera-capture" => {
legacy_camera_capture =
Some(iter.next().map(PathBuf::from).ok_or_else(|| {
"--legacy-camera-capture requires a path".to_string()
})?);
}
"--static-animation-frame" => {
static_animation_frame = Some(
iter.next()
.ok_or_else(|| "--static-animation-frame requires a value".to_string())?
.parse()
.map_err(|_| {
"--static-animation-frame must be a u16 integer".to_string()
})?,
);
}
"--static-material-phase" => {
static_material_phase = Some(
iter.next()
.ok_or_else(|| "--static-material-phase requires a value".to_string())?
.parse()
.map_err(|_| {
"--static-material-phase must be a u16 integer".to_string()
})?,
);
}
_ => return Err(usage()),
}
}
let root = root.ok_or_else(|| "missing --root".to_string())?;
let mission = mission.ok_or_else(|| "missing --mission".to_string())?;
if control_clock_ms.is_some() && frames < 2 {
return Err(
"--control-clock-ms requires --frames of at least 2 to replay the animation clock"
.to_string(),
);
}
if readback_sequence_out.is_some() && control_clock_ms.is_none() {
return Err(
"--readback-sequence-out requires --control-clock-ms for a reproducible sequence"
.to_string(),
);
}
Ok(Self {
root,
mission,
frames,
atmosphere_seconds,
control_clock_ms,
validation,
readback_out,
readback_sequence_out,
preview_roots,
legacy_camera_capture,
static_animation_frame,
static_material_phase,
})
}
}
fn usage() -> String {
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--readback-sequence-out <directory>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/FX clock from zero to the selected time over the requested frames; --readback-sequence-out captures each submitted frame and requires that clock.".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use fparkan_animation::{AnimKey24, Pose};
use fparkan_assets::{PreparedControlTimelineRow, PreparedControlTypedRecord};
use fparkan_fx::environment::{WeatherInput, WeatherState};
use fparkan_msh::{Batch, ModelAnimation, Slot};
use fparkan_path::{normalize_relative, PathPolicy};
use fparkan_resource::{resource_name, ResourceKey};
fn facing_mesh(
batch_flags: u16,
positions: &[[f32; 3]],
) -> (VulkanStaticMesh, VulkanStaticDrawRange) {
let range = VulkanStaticDrawRange {
first_index: 0,
index_count: u32::try_from(positions.len()).expect("fixture length fits u32"),
material_index: 0,
lightmap_index: 0,
batch_flags,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
};
let mesh = VulkanStaticMesh {
vertices: positions
.iter()
.copied()
.map(|position| VulkanStaticVertex {
position,
color: [1.0; 3],
normal: [0.0, 0.0, 1.0],
uv: [0.0; 2],
detail_uv: [0.0; 2],
overlay_alpha: 0.0,
})
.collect(),
indices: (0..positions.len())
.map(|index| u32::try_from(index).expect("fixture index fits u32"))
.collect(),
draw_ranges: vec![range],
};
(mesh, range)
}
fn native_front_triangle() -> [[f32; 3]; 3] {
[[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]]
}
fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] {
let center = add3(camera.position, scale3(camera.forward, 10.0));
let mirror = if mirror { -1.0 } else { 1.0 };
[
add3(
add3(center, scale3(camera.right, -2.0 * mirror)),
scale3(camera.up, -2.0),
),
add3(
add3(center, scale3(camera.right, 2.0 * mirror)),
scale3(camera.up, -2.0),
),
add3(center, scale3(camera.up, 2.0)),
]
}
fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 {
let screen = triangle.map(|point| {
let clip =
row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world);
assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite()));
// This is the positive-height viewport transform used by the Vulkan renderer.
[
(clip[0] / clip[3] + 1.0) * 0.5,
(clip[1] / clip[3] + 1.0) * 0.5,
]
});
let shoelace = (0..3)
.map(|index| {
let next = (index + 1) % 3;
screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1]
})
.sum::<f32>()
* 0.5;
// Vulkan's framebuffer signed-area definition is the negative shoelace area.
-shoelace
}
#[test]
fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() {
let identity = RawCameraTransform {
words: [
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1280, 1024],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection)
.expect("valid legacy camera projection");
let free_flight = FreeFlightCamera {
position: legacy.position,
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.3,
near_plane: 0.5,
far_plane: 700.0,
move_speed: 1.0,
ground_clearance: 0.0,
}
.preview_frame(1280.0 / 1024.0);
for camera in [legacy, free_flight] {
let front = camera_face_triangle(camera, false);
let back = [front[0], front[2], front[1]];
let front_d = dot3(
sub3(camera.position, front[0]),
cross3(sub3(front[1], front[0]), sub3(front[2], front[0])),
);
let back_d = dot3(
sub3(camera.position, back[0]),
cross3(sub3(back[1], back[0]), sub3(back[2], back[0])),
);
assert!(front_d > 0.0);
assert!(back_d < 0.0);
let front_vk_area = projected_vulkan_area(camera, front);
let back_vk_area = projected_vulkan_area(camera, back);
// Vulkan CLOCKWISE front faces have negative framebuffer area.
// The native-visible d>0 face is therefore a Vulkan back face.
assert!(front_vk_area > 0.0, "front area: {front_vk_area}");
assert!(back_vk_area < 0.0, "back area: {back_vk_area}");
assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace);
let mirrored_front = camera_face_triangle(camera, true);
let mirrored_d = dot3(
sub3(camera.position, mirrored_front[0]),
cross3(
sub3(mirrored_front[1], mirrored_front[0]),
sub3(mirrored_front[2], mirrored_front[0]),
),
);
assert!(mirrored_d < 0.0);
assert!(projected_vulkan_area(camera, mirrored_front) < 0.0);
assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace);
}
}
#[test]
fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() {
let rotated = LegacyIron3dEulerTransform {
translation: [8.0, -4.0, 3.0],
orientation_radians: [0.4, -0.7, 1.2],
};
assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1);
assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1);
assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0);
assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled);
}
#[test]
fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() {
let front = native_front_triangle();
let back = front.map(|point| [-point[0], point[1], point[2]]);
let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]];
let (mesh, range) = facing_mesh(0x4000, &front_then_back);
assert!(native_msh_first_triangle_visible(
&mesh, &range, [0.0; 3], 1
));
let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]];
let (mesh, range) = facing_mesh(0x4000, &back_then_front);
assert!(!native_msh_first_triangle_visible(
&mesh, &range, [0.0; 3], 1
));
let (mesh, range) = facing_mesh(0x4002, &back_then_front);
assert!(native_msh_first_triangle_visible(
&mesh, &range, [0.0; 3], 1
));
assert_eq!(
native_msh_cull_mode(range.batch_flags, 1),
LegacyCullMode::Disabled
);
}
#[test]
fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() {
let triangle = native_front_triangle();
let (mesh, range) = facing_mesh(0x4000, &triangle);
let original_count = range.index_count;
assert_eq!(
world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)),
0
);
assert_eq!(
world_draw_range_index_count(
&mesh,
&range,
original_count,
1.0,
[0.0, 0.0, -2.0],
Some(1),
),
0
);
assert_eq!(
world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)),
original_count
);
assert_eq!(
world_draw_range_index_count(
&mesh,
&range,
original_count,
1.0,
[0.0, 0.0, -2.0],
None,
),
original_count
);
}
#[test]
fn world_draw_range_count_clears_and_reapplies_constant_alpha_override() {
let triangle = native_front_triangle();
let (mesh, mut range) = facing_mesh(0x0148, &triangle);
let original_count = range.index_count;
let mut alpha_override = None;
let update = |range: &VulkanStaticDrawRange,
eye_world: [f32; 3],
alpha_override: &mut Option<f32>| {
let mut current_mesh = mesh.clone();
current_mesh.draw_ranges[0] = *range;
*alpha_override =
native_msh_diffuse_alpha_override(&current_mesh, 0, false, eye_world, 1.3).unwrap();
world_draw_range_index_count(
&mesh,
range,
original_count,
alpha_override.unwrap_or(1.0),
eye_world,
None,
)
};
assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0);
range.batch_flags = 0x0108;
assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0);
assert_eq!(
update(&range, [150.0; 3], &mut alpha_override),
original_count
);
range.batch_flags = 0x0148;
assert_eq!(update(&range, [150.0; 3], &mut alpha_override), 0);
assert_eq!(original_count, 3);
}
fn identity_matrix() -> [[f32; 4]; 4] {
[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
}
fn node38_fixture(parent: u16, map_start: u16, fallback: u16, drawable: bool) -> Vec<u8> {
let mut node = Vec::with_capacity(38);
node.extend_from_slice(&[0, 0]);
node.extend_from_slice(&parent.to_le_bytes());
node.extend_from_slice(&map_start.to_le_bytes());
node.extend_from_slice(&fallback.to_le_bytes());
for slot in 0..15 {
let value = if drawable && slot == 0 { 0 } else { u16::MAX };
node.extend_from_slice(&value.to_le_bytes());
}
node
}
fn animated_socket_model() -> ModelAsset {
let mut nodes = node38_fixture(u16::MAX, 0, 1, true);
nodes.extend(node38_fixture(0, u16::MAX, 2, false));
ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 2.0,
slots: vec![Slot {
tri_start: 0,
tri_count: 1,
batch_start: 0,
batch_count: 1,
aabb_min: [-1.0; 3],
aabb_max: [1.0; 3],
sphere_center: [0.0; 3],
sphere_radius: 1.0,
opaque: [0; 5],
}],
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
normals: Some(vec![[127, 0, 0, 0]; 3]),
uv0: None,
uv1: None,
type16_present: false,
indices: vec![0, 1, 2],
batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
lightmap_index: 0,
local_batch_index: 0,
index_count: 3,
index_start: 0,
vertex_count: 3,
base_vertex: 0,
}],
triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose::default(),
},
AnimKey24 {
time: AnimationTime(1.0),
pose: Pose {
translation: [0.0; 3],
rotation: [
0.0,
0.0,
std::f32::consts::FRAC_1_SQRT_2,
std::f32::consts::FRAC_1_SQRT_2,
],
},
},
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [0.0, 2.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
],
frame_map: vec![0, 1],
frame_count: 2,
}),
}
}
fn static_child_model() -> ModelAsset {
let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
nodes.extend(node38_fixture(0, u16::MAX, 0, true));
ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 1.0,
slots: vec![Slot {
tri_start: 0,
tri_count: 1,
batch_start: 0,
batch_count: 1,
aabb_min: [-1.0; 3],
aabb_max: [1.0; 3],
sphere_center: [0.0; 3],
sphere_radius: 1.0,
opaque: [0; 5],
}],
positions: vec![[0.0, 0.0, 0.0]; 3],
normals: Some(vec![[0, 0, 127, 0]; 3]),
uv0: None,
uv1: None,
type16_present: false,
indices: vec![0, 1, 2],
batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
lightmap_index: 0,
local_batch_index: 0,
index_count: 3,
index_start: 0,
vertex_count: 3,
base_vertex: 0,
}],
triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![AnimKey24 {
time: AnimationTime(0.0),
pose: Pose::default(),
}],
frame_map: vec![],
frame_count: 0,
}),
}
}
fn collision_fixture_model(root_drawable: bool) -> ModelAsset {
let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, root_drawable);
nodes.extend(node38_fixture(0, 0, 1, true));
ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 3.0,
slots: vec![Slot {
tri_start: 0,
tri_count: 1,
batch_start: 0,
batch_count: 1,
aabb_min: [-2.0, 0.0, -2.0],
aabb_max: [2.0, 0.0, 2.0],
sphere_center: [0.0; 3],
sphere_radius: 2.0,
opaque: [0; 5],
}],
positions: vec![[-2.0, 0.0, -2.0], [0.0, 0.0, 2.0], [2.0, 0.0, -2.0]],
normals: Some(vec![[0, 127, 0, 0]; 3]),
uv0: None,
uv1: None,
type16_present: false,
indices: vec![0, 1, 2],
batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
lightmap_index: 0,
local_batch_index: 0,
index_count: 3,
index_start: 0,
vertex_count: 3,
base_vertex: 0,
}],
triangle_descriptors: Some(vec![fparkan_msh::TriangleDescriptor {
tri_flags: 0,
opaque: [0; 4],
normal: [0, 32767, 0],
}]),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose::default(),
},
AnimKey24 {
time: AnimationTime(1.0),
pose: Pose {
translation: [20.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
],
frame_map: vec![0, 1],
frame_count: 2,
}),
}
}
#[test]
fn mounted_static_building_binding_matches_renderer_and_hides_root_node() {
let model = collision_fixture_model(true);
let binding = building_collision_binding(
4,
5,
"static fixture model".to_string(),
Arc::new(model.clone()),
LegacyIron3dEulerTransform {
translation: [13.0, -4.0, 9.0],
orientation_radians: [0.4, -0.3, 0.7],
},
[2.0, 3.0, 0.5],
true,
)
.expect("static building geometry still creates a collision binding");
assert_eq!(
binding.selected_nodes,
vec![(fparkan_msh::NodeId(1), fparkan_msh::SlotId(0))]
);
let root = VulkanNodePose {
translation: [1.0, 2.0, 3.0],
rotation: [0.0, 0.0, 0.29552022, 0.9553365],
};
let moving_node = compose_unit_pose(
root,
VulkanNodePose {
translation: [2.0, -1.0, 0.5],
rotation: [0.0, 0.19866933, 0.0, 0.9800666],
},
);
let hierarchy = NodePoseBuffer {
poses: [root, moving_node]
.into_iter()
.map(|pose| Pose {
translation: pose.translation,
rotation: pose.rotation,
})
.collect(),
};
let mount = VulkanNodePose {
translation: [5.0, 7.0, 11.0],
rotation: [0.0, 0.0, 0.38268343, 0.9238795],
};
let nodes = building_collision_nodes(&binding, Some(&hierarchy), Some(mount))
.expect("mounted collision matrices");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].node, fparkan_msh::NodeId(1));
let projected = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
&model,
binding.root_transform,
binding.owner_scale,
&hierarchy,
mount,
)
.expect("mounted renderer projection");
assert_eq!(projected.vertices.len(), 3);
for (actual, source) in projected.vertices.iter().zip(&model.positions) {
let matrix = nodes[0].world_from_node;
let expected = [
matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3],
matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7],
matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11],
];
for axis in 0..3 {
assert!((actual.position[axis] - expected[axis]).abs() < 1.0e-4);
}
}
}
fn doorway_collision_model() -> ModelAsset {
let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
nodes.extend(node38_fixture(0, u16::MAX, 0, true));
ModelAsset {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
bounding_sphere_center: [0.0; 3],
bounding_sphere_radius: 5.0,
slots: vec![Slot {
tri_start: 0,
tri_count: 4,
batch_start: 0,
batch_count: 1,
aabb_min: [-4.0, 0.0, -2.0],
aabb_max: [4.0, 0.0, 2.0],
sphere_center: [0.0; 3],
sphere_radius: 5.0,
opaque: [0; 5],
}],
positions: vec![
[-4.0, 0.0, -2.0],
[-1.0, 0.0, -2.0],
[-1.0, 0.0, 2.0],
[-4.0, 0.0, 2.0],
[1.0, 0.0, -2.0],
[4.0, 0.0, -2.0],
[4.0, 0.0, 2.0],
[1.0, 0.0, 2.0],
],
normals: None,
uv0: None,
uv1: None,
type16_present: false,
indices: vec![0, 3, 2, 0, 2, 1, 4, 7, 6, 4, 6, 5],
batches: vec![Batch {
batch_flags: 0,
material_index_hi: 0,
material_index: 0,
lightmap_index: 0,
local_batch_index: 0,
index_count: 12,
index_start: 0,
vertex_count: 8,
base_vertex: 0,
}],
triangle_descriptors: Some(
(0..4)
.map(|_| fparkan_msh::TriangleDescriptor {
tri_flags: 0,
opaque: [0; 4],
normal: [0, 32767, 0],
})
.collect(),
),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![AnimKey24 {
time: AnimationTime(0.0),
pose: Pose::default(),
}],
frame_map: Vec::new(),
frame_count: 0,
}),
}
}
fn collision_test_app(
model: Arc<ModelAsset>,
root_transform: LegacyIron3dEulerTransform,
position: [f32; 3],
) -> StaticVulkanApp {
let (mut app, _) = clock_test_app(None);
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
.expect("fixture static preview pose")
.expect("fixture Node38 pose");
app.component_pose_buffers = vec![vec![Some(hierarchy)]];
app.component_mount_poses = vec![vec![None]];
app.dynamic_component_geometry = vec![vec![None]];
app.control_animation_instances = vec![vec![None]];
app.building_collision_bindings = vec![building_collision_binding(
0,
0,
"collision fixture".to_string(),
Arc::clone(&model),
root_transform,
[1.0; 3],
false,
)
.expect("fixture building collision binding")];
app.free_camera = Some(FreeFlightCamera {
position,
yaw: -std::f32::consts::FRAC_PI_2,
pitch: 0.0,
horizontal_fov: 1.3,
near_plane: 0.5,
far_plane: 700.0,
move_speed: 60.0,
ground_clearance: 0.0,
});
app.pressed_keys.insert(KeyCode::KeyW);
app
}
#[test]
fn high_speed_free_flight_stops_at_static_building_wall() {
let mut app = collision_test_app(
Arc::new(collision_fixture_model(false)),
LegacyIron3dEulerTransform {
translation: [0.0, 5.0, 0.0],
orientation_radians: [0.0; 3],
},
[0.0, 8.0, 0.0],
);
app.update_free_camera(0.1, [1280.0, 720.0])
.expect("continuous building query");
assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]);
}
#[test]
fn small_nonzero_building_scale_remains_queryable() {
let model = Arc::new(collision_fixture_model(false));
let binding = building_collision_binding(
0,
0,
"tiny-scale fixture".to_string(),
Arc::clone(&model),
LegacyIron3dEulerTransform {
translation: [0.0, 5.0, 0.0],
orientation_radians: [0.0; 3],
},
[1.0e-8, 1.0, 1.0],
false,
)
.expect("small nonzero scale is invertible");
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
.expect("fixture static preview pose")
.expect("fixture Node38 pose");
let nodes = building_collision_nodes(&binding, Some(&hierarchy), None)
.expect("small-scale collision matrix");
assert!(query_building_segment_world(
&binding.model,
&nodes,
[[0.0, 8.0, 0.0], [0.0, 2.0, 0.0]],
)
.expect("small-scale query")
.is_some());
}
#[test]
fn free_flight_passes_through_authored_doorway() {
let mut app = collision_test_app(
Arc::new(doorway_collision_model()),
LegacyIron3dEulerTransform {
translation: [0.0, 5.0, 0.0],
orientation_radians: [0.0; 3],
},
[0.0, 8.0, 0.0],
);
app.update_free_camera(0.1, [1280.0, 720.0])
.expect("doorway query");
let position = app.free_camera.unwrap().position;
assert!((position[1] - 2.0).abs() < 1.0e-4);
}
#[test]
fn shared_building_models_keep_each_placed_instance_query_separate() {
let model = Arc::new(collision_fixture_model(false));
let far = building_collision_binding(
0,
0,
"shared fixture".to_string(),
Arc::clone(&model),
LegacyIron3dEulerTransform {
translation: [0.0, 100.0, 0.0],
orientation_radians: [0.0; 3],
},
[1.0; 3],
false,
)
.expect("first placed building");
let near = building_collision_binding(
1,
0,
"shared fixture".to_string(),
Arc::clone(&model),
LegacyIron3dEulerTransform {
translation: [0.0, 5.0, 0.0],
orientation_radians: [0.0; 3],
},
[1.0; 3],
false,
)
.expect("second placed building");
assert!(Arc::ptr_eq(&far.model, &near.model));
let (mut app, _) = clock_test_app(None);
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
.expect("shared fixture static pose")
.expect("shared fixture Node38 pose");
app.component_pose_buffers = vec![vec![Some(hierarchy.clone())], vec![Some(hierarchy)]];
app.component_mount_poses = vec![vec![None], vec![None]];
app.building_collision_bindings = vec![far, near];
app.free_camera.as_mut().unwrap().position = [0.0, 8.0, 0.0];
app.free_camera.as_mut().unwrap().yaw = -std::f32::consts::FRAC_PI_2;
app.free_camera.as_mut().unwrap().pitch = 0.0;
app.free_camera.as_mut().unwrap().move_speed = 60.0;
app.pressed_keys.insert(KeyCode::KeyW);
app.update_free_camera(0.1, [1280.0, 720.0])
.expect("both placed buildings are queried independently");
assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]);
}
#[test]
fn collision_uses_updated_control_pose_and_retries_do_not_move_again() {
let model = Arc::new(collision_fixture_model(false));
let mut app = collision_test_app(
Arc::clone(&model),
LegacyIron3dEulerTransform {
translation: [0.0, 5.0, 0.0],
orientation_radians: [0.0; 3],
},
[0.0, 8.0, 0.0],
);
let mut control = socket_timeline_control();
control.timeline_rows[0].node_index = 1;
app.control_animation_instances[0][0] = Some(ControlAnimationInstance {
control,
model: (*model).clone(),
gate_values: vec![Some(0.0)],
state: ControlInstanceState::default(),
last_sampled_node_times: Vec::new(),
reported_unsupported_fallback: false,
});
let first = FrameClockSample {
id: SceneSampleId::Replay(0),
time_ms: 0,
delta_seconds: 0.1,
};
app.prepare_scene_update(first, [1280.0, 720.0])
.expect("closed moving wall blocks the first frame");
let safe_position = app.free_camera.unwrap().position;
let closed_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation;
assert_eq!(safe_position, [0.0, 8.0, 0.0]);
app.control_animation_instances[0][0]
.as_mut()
.unwrap()
.gate_values[0] = Some(1.0);
app.prepare_scene_update(first, [1024.0, 768.0])
.expect("pending retry only refreshes its viewport");
assert_eq!(app.free_camera.unwrap().position, safe_position);
assert_eq!(
app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation,
closed_pose
);
app.prepare_scene_update(
FrameClockSample {
id: SceneSampleId::Replay(1),
time_ms: 1000,
delta_seconds: 0.1,
},
[1024.0, 768.0],
)
.expect("the next sampled pose opens the path");
let open_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation;
assert!(open_pose[0] > 2.0, "door node did not move: {open_pose:?}");
assert!((app.free_camera.unwrap().position[1] - 2.0).abs() < 1.0e-4);
}
#[test]
fn missing_free_camera_keeps_legacy_view_without_querying_buildings() {
let (mut app, _) = clock_test_app(None);
app.free_camera = None;
app.building_collision_bindings
.push(BuildingCollisionBinding {
object_index: 0,
component_index: 0,
model_source: "legacy fixture".to_string(),
model: Arc::new(collision_fixture_model(false)),
root_transform: LegacyIron3dEulerTransform {
translation: [0.0; 3],
orientation_radians: [0.0; 3],
},
owner_scale: [1.0; 3],
mounted: false,
selected_nodes: vec![(NodeId(u32::MAX), SlotId(u32::MAX))],
});
let camera = app.camera;
let view_camera = app.view_camera;
app.update_free_camera(0.1, [1024.0, 768.0])
.expect("legacy camera skips free-flight collision");
assert_eq!(app.camera, camera);
assert_eq!(app.view_camera, view_camera);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_plant_wall_and_door_use_the_app_camera_bridge() {
use std::path::PathBuf;
const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]];
const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
let root = PathBuf::from(
std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
.expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed tests"),
);
let mission = "MISSIONS/Autodemo.00/data.tma";
let args = Args::parse(&[
"--root".to_string(),
root.to_string_lossy().into_owned(),
"--mission".to_string(),
mission.to_string(),
])
.expect("licensed AutoDemo app arguments");
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))))
.expect("create licensed AutoDemo engine");
let loaded = load_requested_mission(&mut engine, &args).expect("load AutoDemo mission");
let assets = loaded_mission_assets(&engine).expect("AutoDemo assets");
let terrain = loaded_terrain(&engine).expect("AutoDemo terrain");
let roots = loaded_mission_object_drafts(&engine).expect("AutoDemo object drafts");
let preview = static_preview_mesh_and_materials(
assets,
terrain,
roots,
None,
None,
None,
&args.root,
&loaded.land_msh_path,
)
.expect("build AutoDemo static preview");
let mut component_bindings = preview
.building_collision_bindings
.iter()
.map(|binding| (binding.object_index, binding.component_index))
.collect::<Vec<_>>();
component_bindings.sort_unstable();
assert_eq!(
component_bindings,
vec![(4, 0), (4, 5), (5, 0), (5, 5), (6, 0), (7, 0)]
);
let shadow_range = EnvironmentGpuRange {
range_index: preview.mesh.draw_ranges.len(),
vertex_start: 0,
vertex_capacity: 0,
material_index: preview.shadow_material_index,
};
let mut app = StaticVulkanApp::new(
preview,
None,
None,
shadow_range,
0,
0,
None,
2,
mission,
loaded.object_count,
None,
None,
false,
None,
StartupTimings::default(),
);
let plant = app
.building_collision_bindings
.iter()
.find(|binding| binding.object_index == 4 && binding.component_index == 0)
.cloned()
.expect("static PLANT root collision binding");
let hierarchy = app.component_pose_buffers[4][0]
.as_ref()
.expect("PLANT current root pose");
let plant_nodes =
building_collision_nodes(&plant, Some(hierarchy), app.component_mount_poses[4][0])
.expect("PLANT current collision nodes");
let plant_node = plant_nodes
.iter()
.copied()
.find(|node| node.node == NodeId(1) && node.slot == SlotId(0))
.expect("native fixture node and slot");
let body_to_world = |point| {
plant
.root_transform
.try_transform_scaled_point(point, plant.owner_scale)
.expect("finite placed-body transform")
};
let world_wall = BODY_WALL.map(body_to_world);
let world_door = BODY_DOOR.map(body_to_world);
let direct_wall_hit = query_building_segment_world(&plant.model, &[plant_node], world_wall)
.expect("direct PLANT body-space query")
.expect("verified PLANT wall hit");
assert_eq!(direct_wall_hit.node, NodeId(1));
assert_eq!(direct_wall_hit.slot, SlotId(0));
assert_eq!(direct_wall_hit.batch_index, 5);
assert_eq!(direct_wall_hit.triangle_descriptor_index, 62);
let closed_door_hit = query_building_segment_world(&plant.model, &plant_nodes, world_door)
.expect("linked PLANT door query")
.expect("the default Node3 door blocks the center path");
assert_eq!(closed_door_hit.node, NodeId(3));
assert_eq!(closed_door_hit.batch_index, 100);
assert_eq!(closed_door_hit.triangle_descriptor_index, 855);
let terrain_bounds = app
.terrain
.surface_bounds()
.expect("AutoDemo terrain surface bounds");
for point in world_wall.into_iter().chain(world_door) {
for axis in 0..2 {
assert!(
(terrain_bounds[0][axis]..=terrain_bounds[1][axis]).contains(&point[axis]),
"native fixture endpoint {point:?} is outside terrain bounds {terrain_bounds:?}"
);
}
let floor = app
.terrain
.height_at([point[0], point[1]])
.expect("fixture terrain query");
if let Some(floor) = floor {
assert!(
point[2] >= floor,
"native fixture height {} is below terrain {floor} at {point:?}",
point[2]
);
} else {
let foundation = app
.terrain
.basement_foundation()
.expect("missing terrain height needs a BAS foundation");
assert!(!foundation.fragments.is_empty());
let source_floor = terrain
.height_at([point[0], point[1]])
.expect("source fixture terrain query")
.expect("BAS-cut fixture endpoint has source Land.msh terrain");
eprintln!(
"native fixture endpoint {point:?} has source Land.msh floor {source_floor} but no BAS-foundation surface"
);
}
let mut constrained = FreeFlightCamera {
position: point,
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.3,
near_plane: 0.5,
far_plane: 700.0,
move_speed: 0.0,
ground_clearance: 0.0,
};
constrained
.keep_above_terrain(&app.terrain)
.expect("terrain constraint preserves native fixture height");
assert_eq!(constrained.position, point);
}
set_collision_test_segment(&mut app, world_wall);
let zero_delta = FrameClockSample {
id: SceneSampleId::Replay(0),
time_ms: 0,
delta_seconds: 0.0,
};
app.prepare_scene_update(zero_delta, [1280.0, 720.0])
.expect("zero-delta resume initialization");
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
assert_eq!(app.record_presented_scene_frame(), 0);
let wall_frame = FrameClockSample {
id: SceneSampleId::Replay(1),
time_ms: 0,
delta_seconds: 0.1,
};
app.prepare_scene_update(wall_frame, [1280.0, 720.0])
.expect("high-speed wall movement through the app update path");
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
app.pressed_keys.clear();
app.pressed_keys.insert(KeyCode::KeyD);
app.prepare_scene_update(wall_frame, [1024.0, 768.0])
.expect("retry of the pending wall frame");
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
assert_eq!(app.record_presented_scene_frame(), 1);
set_collision_test_segment(&mut app, world_door);
app.prepare_scene_update(
FrameClockSample {
id: SceneSampleId::Replay(2),
time_ms: 0,
delta_seconds: 0.1,
},
[1024.0, 768.0],
)
.expect("the closed Node3 door blocks through the app update path");
assert_eq!(app.free_camera.unwrap().position, world_door[0]);
assert_eq!(app.record_presented_scene_frame(), 2);
let control = assets
.object_component_controls
.get(4)
.and_then(|components| components.first())
.and_then(Option::as_ref)
.expect("PLANT root retains its CTLD control");
let node3_binding = control
.bindings
.iter()
.find(|binding| binding.node_index == 3)
.expect("PLANT CTLD binding for the authored Node3 door");
assert_eq!(node3_binding.frame_a, 0.0);
assert_eq!(node3_binding.frame_b, 1.0);
assert_eq!(node3_binding.initial_blend, 0.0);
assert_eq!(
control
.typed_records
.iter()
.filter(|record| record.tag == 21)
.count(),
0,
"the PLANT control has no tag-21 record to advance Node3 from its initial frame"
);
let mut open_node_times =
static_preview_node_times(plant.model.node_count, &control.bindings)
.expect("actual initial PLANT CTLD node times");
assert_eq!(open_node_times[3], Some(AnimationTime(0.0)));
// This samples the door's authored frame B directly; it does not simulate a mission gate.
open_node_times[3] = Some(AnimationTime(1.0));
let open_hierarchy = node38_sampled_hierarchy_at_times(&plant.model, &open_node_times)
.expect("sample the actual PLANT model at its authored Node3 frame B");
app.component_pose_buffers[4][0] = Some(open_hierarchy);
update_dynamic_component_geometry(
&app.dynamic_component_geometry,
&app.component_pose_buffers,
&mut app.component_mount_poses,
&mut app.mesh,
)
.expect("update the scene hierarchy for the authored door pose");
let open_hierarchy = app.component_pose_buffers[4][0]
.as_ref()
.expect("sampled PLANT root pose");
let open_nodes = building_collision_nodes(
&plant,
Some(open_hierarchy),
app.component_mount_poses[4][0],
)
.expect("sampled PLANT collision nodes");
assert!(
query_building_segment_world(&plant.model, &open_nodes, world_door)
.expect("open PLANT door query")
.is_none(),
"the authored Node3 time-1 pose leaves the doorway ray clear"
);
set_collision_test_segment(&mut app, world_door);
app.update_free_camera(0.1, [1024.0, 768.0])
.expect("open doorway movement through the app camera update");
let position = app.free_camera.unwrap().position;
for axis in 0..3 {
assert!((position[axis] - world_door[1][axis]).abs() < 1.0e-4);
}
}
fn set_collision_test_segment(app: &mut StaticVulkanApp, segment: [[f32; 3]; 2]) {
let delta = sub3(segment[1], segment[0]);
let horizontal_distance = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt();
app.free_camera = Some(FreeFlightCamera {
position: segment[0],
yaw: delta[1].atan2(delta[0]),
pitch: delta[2].atan2(horizontal_distance),
horizontal_fov: 1.3,
near_plane: 0.5,
far_plane: 700.0,
move_speed: length3(delta).expect("nonzero native test segment") / 0.1,
ground_clearance: 0.0,
});
app.pressed_keys.clear();
app.pressed_keys.insert(KeyCode::KeyW);
}
fn socket_timeline_control() -> PreparedComponentControl {
let mut fixed_words = [0; 44];
fixed_words[2] = 0;
fixed_words[6] = u32::MAX;
fixed_words[9] = 1.0_f32.to_bits();
fixed_words[10] = 1.0_f32.to_bits();
PreparedComponentControl {
source: ResourceKey {
archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
.expect("archive path"),
name: resource_name(b"animated.ctl"),
type_id: None,
},
native_profile_words: [0; 27],
model_node_count: 2,
bindings: vec![],
timeline_rows: vec![PreparedControlTimelineRow {
row_index: 0,
node_index: 0,
frame_a: 0.0,
frame_b: 1.0,
initial_blend: 0.0,
raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
flags: 0,
}],
typed_records: vec![PreparedControlTypedRecord {
tag: 21,
fixed_words,
values: vec![0],
string_bytes: vec![],
gate_provider: PreparedControlGateProvider::Resolved {
key: 0,
ndp_source: ResourceKey {
archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
.expect("NDP archive path"),
name: resource_name(b"animated.ndp"),
type_id: None,
},
row_count: 1,
initial_type1_value: 1.0,
},
}],
node_data: None,
effect_section: PreparedControlEffectSection::Absent,
initial_effect_group: None,
point_list: None,
}
}
fn control_point(position: [f32; 3], direction: [f32; 3]) -> PreparedControlPoint {
PreparedControlPoint {
raw_flags: 0,
raw_signed_field_04: 0,
raw_signed_field_08: 0,
position,
direction,
name: [0; 32],
}
}
fn assert_matrix_columns(matrix: [[f32; 4]; 4], expected: [[f32; 3]; 3]) {
for (column, expected) in expected.into_iter().enumerate() {
for row in 0..3 {
assert!(
(matrix[row][column] - expected[row]).abs() < 1.0e-5,
"column {column} row {row}: {} != {}",
matrix[row][column],
expected[row]
);
}
}
}
fn write_effect_u32(body: &mut [u8], offset: usize, value: u32) {
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
fn write_effect_f32(body: &mut [u8], offset: usize, value: f32) {
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
fn write_effect_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) {
for (axis, component) in value.into_iter().enumerate() {
write_effect_f32(body, offset + axis * 4, component);
}
}
#[test]
fn tag21_clock_moves_msh_vertices_normals_child_socket_and_fx_anchor() {
let parent_model = animated_socket_model();
let child_model = static_child_model();
let identity_transform = LegacyIron3dEulerTransform {
translation: [0.0; 3],
orientation_radians: [0.0; 3],
};
let initial_parent_pose =
node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
.expect("initial parent pose");
let initial_child_pose =
node38_fallback_hierarchy(&child_model).expect("static child pose");
let initial_mount =
node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("parent socket pose");
let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
&parent_model,
identity_transform,
[1.0; 3],
&initial_parent_pose,
)
.expect("initial parent mesh");
let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
&child_model,
identity_transform,
[1.0; 3],
&initial_child_pose,
initial_mount,
)
.expect("initial mounted child mesh");
let parent_vertex_count = parent_mesh.vertices.len();
assert_eq!(parent_vertex_count, 3);
assert_eq!(child_mesh.vertices.len(), 3);
let mut vertices = parent_mesh
.vertices
.into_iter()
.chain(child_mesh.vertices)
.collect::<Vec<_>>();
let environment_vertex_start =
u32::try_from(vertices.len()).expect("environment vertex base fits u32");
let mut environment_vertices = shadow_placeholder_vertices();
for vertex in &mut environment_vertices {
vertex.position[0] += 1000.0;
}
vertices.extend(environment_vertices);
let mut indices = vec![0, 1, 2, 3, 4, 5];
indices.extend(
quad_indices(1)
.expect("environment quad indices")
.into_iter()
.map(|index| index + environment_vertex_start),
);
let draw_range = |first_index, index_count| VulkanStaticDrawRange {
first_index,
index_count,
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: world_pipeline_state(
LegacyBlendMode::Opaque,
LegacyDepthMode::TestReadOnly,
),
terrain_base_only: false,
alpha_test_reference: 0,
};
let mut mesh = VulkanStaticMesh {
vertices,
indices,
draw_ranges: vec![draw_range(0, 3), draw_range(3, 3), draw_range(6, 6)],
};
let control = socket_timeline_control();
let gate_values = initial_control_gate_values(&control);
let mut shadow_casters = vec![test_shadow_caster(0, [0.0; 3])];
shadow_casters[0].triangles = static_mesh_shadow_triangles_in_index_range(&mesh, 0, 6)
.expect("initial caster triangle geometry");
let dynamic_shadow_casters = [DynamicShadowCaster {
caster_index: 0,
object_index: 0,
root_transform: identity_transform,
owner_scale: [1.0; 3],
initial_sphere: shadow_casters[0].sphere,
component_draw_range_indices: vec![0, 1],
component_spheres: vec![
DynamicShadowSphereSource {
component_index: 0,
center: [0.0; 3],
radius: 1.0,
mounted: false,
},
DynamicShadowSphereSource {
component_index: 1,
center: [0.0; 3],
radius: 1.0,
mounted: true,
},
],
}];
let mut instances = vec![vec![
Some(ControlAnimationInstance {
control,
model: parent_model.clone(),
gate_values,
state: ControlInstanceState::default(),
last_sampled_node_times: Vec::new(),
reported_unsupported_fallback: false,
}),
None,
]];
let mut poses = vec![vec![Some(initial_parent_pose), Some(initial_child_pose)]];
let mut mounts = vec![vec![None, Some(initial_mount)]];
let geometry = vec![vec![
Some(DynamicComponentGeometry {
model: parent_model,
root_transform: identity_transform,
owner_scale: [1.0; 3],
parent_mount: None,
vertex_start: 0,
vertex_count: parent_vertex_count,
}),
Some(DynamicComponentGeometry {
model: child_model,
root_transform: identity_transform,
owner_scale: [1.0; 3],
parent_mount: Some((0, 1)),
vertex_start: parent_vertex_count,
vertex_count: 3,
}),
]];
// The fixture's resolved NDP row supplies the native initial value 1.0.
update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
.expect("initial active timeline sample");
let first_root_pose = poses[0][0].as_ref().expect("first root pose").poses[0];
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
.expect("initial geometry update");
let range_remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("environment reorder");
let mut dynamic_shadow_casters = dynamic_shadow_casters;
remap_dynamic_shadow_caster_draw_ranges(&mut dynamic_shadow_casters, &range_remap)
.expect("dynamic shadow draw-range remap");
refresh_dynamic_shadow_casters(
&mut shadow_casters,
&dynamic_shadow_casters,
&mesh,
&poses,
&mounts,
)
.expect("initial caster geometry refresh");
let encloses_triangles = |sphere: ShadowSphere, triangles: &[ShadowTriangle]| {
triangles
.iter()
.flat_map(|triangle| triangle.positions)
.all(|point| {
let offset = sub3(point, sphere.center);
let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
distance.is_finite() && distance <= sphere.radius + 1.0e-4
})
};
let first_shadow_sphere = shadow_casters[0].sphere;
assert!(encloses_triangles(
first_shadow_sphere,
&shadow_casters[0].triangles
));
assert_eq!(
dynamic_shadow_casters[0].component_draw_range_indices,
[1, 2]
);
assert_eq!(shadow_casters[0].triangles.len(), 2);
assert_eq!(
shadow_casters[0].triangles[0].positions[0],
mesh.vertices[0].position
);
assert_eq!(
shadow_casters[0].triangles[1].positions[0],
mesh.vertices[parent_vertex_count].position
);
let first_shadow_triangles = shadow_casters[0].triangles.clone();
let first_parent_vertex = mesh.vertices[0];
let first_child_vertex = mesh.vertices[parent_vertex_count];
let first_mount = mounts[0][1].expect("initial child mount");
let first_fx_anchor = control_type4_world_from_effect(
identity_matrix(),
[1.0; 3],
identity_transform,
Some(first_mount),
control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
.expect("mounted FX owner pose"),
)
.expect("initial FX anchor");
update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
.expect("later active timeline sample");
let later_root_pose = poses[0][0].as_ref().expect("later root pose").poses[0];
assert_ne!(first_root_pose, later_root_pose);
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
.expect("later geometry update");
refresh_dynamic_shadow_casters(
&mut shadow_casters,
&dynamic_shadow_casters,
&mesh,
&poses,
&mounts,
)
.expect("later caster geometry refresh");
let later_shadow_sphere = shadow_casters[0].sphere;
let later_shadow_triangles = shadow_casters[0].triangles.clone();
assert!(encloses_triangles(
later_shadow_sphere,
&later_shadow_triangles
));
assert_ne!(first_shadow_triangles, shadow_casters[0].triangles);
assert_eq!(
shadow_casters[0].triangles[0].positions[0],
mesh.vertices[0].position
);
let later_parent_vertex = mesh.vertices[0];
let later_child_vertex = mesh.vertices[parent_vertex_count];
let later_mount = mounts[0][1].expect("later child mount");
let later_fx_anchor = control_type4_world_from_effect(
identity_matrix(),
[1.0; 3],
identity_transform,
Some(later_mount),
control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
.expect("mounted FX owner pose"),
)
.expect("later FX anchor");
assert_ne!(first_parent_vertex.position, later_parent_vertex.position);
assert_ne!(first_parent_vertex.normal, later_parent_vertex.normal);
assert_ne!(first_mount.translation, later_mount.translation);
assert_eq!(first_child_vertex.position, first_mount.translation);
assert_eq!(later_child_vertex.position, later_mount.translation);
assert_ne!(first_fx_anchor[0][3], later_fx_anchor[0][3]);
assert_ne!(first_fx_anchor[1][3], later_fx_anchor[1][3]);
update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
.expect("repeat the same timeline sample");
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
.expect("repeat the same geometry update");
refresh_dynamic_shadow_casters(
&mut shadow_casters,
&dynamic_shadow_casters,
&mesh,
&poses,
&mounts,
)
.expect("repeat the same caster refresh");
assert_eq!(shadow_casters[0].sphere, later_shadow_sphere);
assert_eq!(shadow_casters[0].triangles, later_shadow_triangles);
update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
.expect("return to the initial timeline sample");
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
.expect("return to the initial geometry");
refresh_dynamic_shadow_casters(
&mut shadow_casters,
&dynamic_shadow_casters,
&mesh,
&poses,
&mounts,
)
.expect("rebuild the initial caster after a later pose");
assert_eq!(shadow_casters[0].sphere, first_shadow_sphere);
assert_eq!(shadow_casters[0].triangles, first_shadow_triangles);
}
#[test]
fn visible_exto_parent_refreshes_child_mount_without_parent_geometry() {
let make_record = |kind, immediate_child_count| fparkan_prototype::UnitComponentRecord {
archive_raw: [0; 32],
resource_raw: [0; 32],
kind,
parent_or_link: 1,
description_raw: [0; 32],
tail0: 0,
immediate_child_count,
};
let records = vec![
make_record(0, 1),
make_record(PROTOTYPE_TYPE_EXTO, 1),
make_record(PROTOTYPE_TYPE_EXTO, 0),
];
let tree = unit_component_tree(&records).expect("valid three-record component tree");
assert_eq!(tree[2].parent_index, Some(1));
assert!(tree[1].parent_index.expect("parent has root") < 2);
assert!(unit_component_is_visible(1, Some(records[1].kind)));
let identity_transform = LegacyIron3dEulerTransform {
translation: [0.0; 3],
orientation_radians: [0.0; 3],
};
let mut parent_model = animated_socket_model();
parent_model.slots.clear();
parent_model.positions.clear();
parent_model.normals = None;
parent_model.uv0 = None;
parent_model.uv1 = None;
parent_model.indices.clear();
parent_model.batches.clear();
let initial_parent_pose =
node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
.expect("animated parent hierarchy without mesh geometry");
let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
&parent_model,
identity_transform,
[1.0; 3],
&initial_parent_pose,
)
.expect("parent with no geometry projects successfully");
assert!(parent_mesh.vertices.is_empty());
let child_model = static_child_model();
let child_pose = node38_fallback_hierarchy(&child_model).expect("static child pose");
let initial_mount =
node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("visible parent socket");
let mut mounts = vec![vec![None, None, Some(initial_mount)]];
let parent_mount = tree[2]
.parent_index
.filter(|parent_index| {
mounts[0][*parent_index].is_some()
|| unit_component_is_visible(*parent_index, Some(records[*parent_index].kind))
})
.map(|parent_index| {
(
parent_index,
usize::try_from(records[2].parent_or_link).expect("valid EXTO parent socket"),
)
});
assert_eq!(parent_mount, Some((1, 1)));
let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
&child_model,
identity_transform,
[1.0; 3],
&child_pose,
initial_mount,
)
.expect("initial child mesh");
let mut mesh = VulkanStaticMesh {
vertices: child_mesh.vertices,
indices: child_mesh.indices,
draw_ranges: Vec::new(),
};
let control = socket_timeline_control();
let controls = vec![None, Some(control.clone()), None];
assert!(control_tree_chain_has_tag21(2, Some(&tree), &controls));
let mut instances = vec![vec![
None,
Some(ControlAnimationInstance {
gate_values: initial_control_gate_values(&control),
control,
model: parent_model,
state: ControlInstanceState::default(),
last_sampled_node_times: Vec::new(),
reported_unsupported_fallback: false,
}),
None,
]];
let mut poses = vec![vec![None, Some(initial_parent_pose), Some(child_pose)]];
let geometry = vec![vec![
None,
None,
Some(DynamicComponentGeometry {
model: child_model,
root_transform: identity_transform,
owner_scale: [1.0; 3],
parent_mount,
vertex_start: 0,
vertex_count: mesh.vertices.len(),
}),
]];
update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
.expect("initial visible parent timeline sample");
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
.expect("initial child mount refresh");
let initial_child_vertex = mesh.vertices[0].position;
let initial_child_mount = mounts[0][2].expect("initial child mount");
assert_eq!(initial_child_vertex, initial_child_mount.translation);
update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
.expect("later visible parent timeline sample");
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
.expect("child mount refresh without parent geometry descriptor");
let later_child_mount = mounts[0][2].expect("later child mount");
assert_ne!(
initial_child_mount.translation,
later_child_mount.translation
);
assert_eq!(mesh.vertices[0].position, later_child_mount.translation);
}
#[test]
fn dynamic_shadow_sphere_union_keeps_the_containing_input_sphere() {
let small = ShadowSphere {
center: [0.0; 3],
radius: 1.0,
};
let large = ShadowSphere {
center: [1.0, 0.0, 0.0],
radius: 10.0,
};
assert_eq!(
shadow_sphere_enclosing_current_geometry(small, large, &[])
.expect("large current native sphere contains baseline"),
large
);
assert_eq!(
shadow_sphere_enclosing_current_geometry(large, small, &[])
.expect("large baseline sphere contains current native sphere"),
large
);
}
fn endpoint_op3_command() -> FxCommand {
let mut body = vec![0; 0xc4];
write_effect_u32(&mut body, 0, 2);
write_effect_f32(&mut body, 0x04, 0.0);
write_effect_f32(&mut body, 0x08, 1.0);
write_effect_f32(&mut body, 0x0c, 1.0);
write_effect_f32(&mut body, 0x10, 0.0);
write_effect_f32(&mut body, 0x14, 1.0);
write_effect_f32(&mut body, 0x18, 1.0);
write_effect_f32(&mut body, 0x1c, 0.0);
write_effect_f32(&mut body, 0x20, 1.0);
write_effect_vec3(&mut body, 0x24, [0.0; 3]);
write_effect_vec3(&mut body, 0x30, [0.0; 3]);
write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
write_effect_vec3(&mut body, 0x48, [0.0, 0.0, 1.0]);
write_effect_vec3(&mut body, 0x54, [0.0, 0.0, 1.0]);
write_effect_vec3(&mut body, 0x60, [1.0; 3]);
write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
write_effect_vec3(&mut body, 0x78, [1.0; 3]);
body[0x84..0x84 + 11].copy_from_slice(b"effects.rlb");
let name = b"f_smalllight_y";
body[0xa4..0xa4 + name.len()].copy_from_slice(name);
FxCommand {
word: 3,
opcode: FxOpcode::Op3,
native_flag: false,
raw_body: Arc::from(body),
resource_refs: Vec::new(),
}
}
fn endpoint_op1_command(effect_type: u32, start: f32, end: f32) -> FxCommand {
let mut body = vec![0; 132];
write_effect_u32(&mut body, 0, effect_type);
write_effect_f32(&mut body, 4, start);
write_effect_f32(&mut body, 8, end);
write_effect_vec3(&mut body, 12, [1.0, 0.0, 0.0]);
write_effect_vec3(&mut body, 24, [3.0, 0.0, 0.0]);
write_effect_vec3(&mut body, 36, [0.0, 1.0, 0.0]);
write_effect_vec3(&mut body, 48, [0.0, 1.0, 0.0]);
for (index, value) in [0.0, 1.0, 0.5, 1.0].into_iter().enumerate() {
write_effect_f32(&mut body, 60 + index * 4, value);
}
for (index, value) in [0.5, 0.0, 1.0, 1.0].into_iter().enumerate() {
write_effect_f32(&mut body, 76 + index * 4, value);
}
write_effect_f32(&mut body, 108, 10.0);
write_effect_f32(&mut body, 112, 20.0);
write_effect_f32(&mut body, 116, 1.0);
write_effect_vec3(&mut body, 120, [1.0, 0.5, 0.25]);
FxCommand {
word: 1,
opcode: FxOpcode::Op1,
native_flag: false,
raw_body: Arc::from(body),
resource_refs: Vec::new(),
}
}
fn endpoint_gener_ball_command(opcode: FxOpcode) -> FxCommand {
let is_op9 = opcode == FxOpcode::Op9;
let mut body = vec![0; if is_op9 { 0xcc } else { 0xc8 }];
write_effect_u32(&mut body, 0, if is_op9 { 2 } else { 0 });
write_effect_f32(&mut body, 0x04, 0.0);
write_effect_f32(&mut body, 0x08, 1.0);
write_effect_f32(&mut body, 0x0c, 1.0);
write_effect_f32(&mut body, 0x10, 0.0);
write_effect_f32(&mut body, 0x14, 1.0);
write_effect_f32(&mut body, 0x18, 1.0);
write_effect_f32(&mut body, 0x1c, 0.75);
write_effect_f32(&mut body, 0x20, 1.0);
write_effect_vec3(&mut body, 0x24, [0.0; 3]);
write_effect_vec3(&mut body, 0x30, [0.0; 3]);
write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
write_effect_vec3(&mut body, 0x48, [0.0, 1.0, 0.0]);
write_effect_vec3(&mut body, 0x54, [0.0, 1.0, 0.0]);
write_effect_vec3(&mut body, 0x60, [1.0; 3]);
write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
write_effect_vec3(&mut body, 0x78, [1.0; 3]);
body[0x84..0x84 + 12].copy_from_slice(b"material.lib");
let name = if is_op9 {
b"laser_y_hit".as_slice()
} else {
b"glow_y"
};
body[0xa4..0xa4 + name.len()].copy_from_slice(name);
if is_op9 {
write_effect_u32(&mut body, 0xc4, 0);
} else {
write_effect_f32(&mut body, 0xc4, 500.0);
}
FxCommand {
word: if is_op9 { 9 } else { 4 },
opcode,
native_flag: false,
raw_body: Arc::from(body),
resource_refs: Vec::new(),
}
}
fn endpoint_op8_command(pool_count: u32) -> FxCommand {
let mut body = vec![0; 0xf4];
write_effect_u32(&mut body, 0x20, pool_count);
FxCommand {
word: 8,
opcode: FxOpcode::Op8,
native_flag: false,
raw_body: Arc::from(body),
resource_refs: Vec::new(),
}
}
fn endpoint_op7_command(
mode: u32,
material_phase_factor: f32,
acceleration: f32,
pool_count: u32,
copies_per_group: u32,
) -> FxCommand {
let mut body = vec![0; 0xCC];
write_effect_u32(&mut body, 0x00, mode);
write_effect_f32(&mut body, 0x1C, material_phase_factor);
write_effect_u32(&mut body, 0x20, pool_count);
write_effect_u32(&mut body, 0x24, copies_per_group);
write_effect_f32(&mut body, 0x88, acceleration);
FxCommand {
word: 7,
opcode: FxOpcode::Op7,
native_flag: false,
raw_body: Arc::from(body),
resource_refs: Vec::new(),
}
}
fn endpoint_effect_frame(command_index: usize) -> fparkan_fx::fx_visual::EffectQuadFrame {
fparkan_fx::fx_visual::EffectQuadFrame {
command_index,
opcode: FxOpcode::Op7,
material: fparkan_fx::environment::EffectResource {
archive: "material.lib".to_owned(),
name: "smoke_yg".to_owned(),
},
vertices: [[0.0; 3]; 4],
uv: [[0.0; 2]; 4],
color: [1.0; 4],
material_phase: 0.25,
elapsed_seconds: 0.5,
normalized_time: 0.5,
active: true,
native_flag_base: None,
}
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part1_prepares_every_mission() {
prepare_licensed_corpus("FPARKAN_CORPUS_PART1_ROOT", 29);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_part2_prepares_every_mission() {
prepare_licensed_corpus("FPARKAN_CORPUS_PART2_ROOT", 31);
}
fn prepare_licensed_corpus(variable: &str, expected_missions: usize) {
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
let mut missions = Vec::new();
let mut directories = vec![root.clone()];
while let Some(directory) = directories.pop() {
let entries = std::fs::read_dir(&directory)
.unwrap_or_else(|error| panic!("read {}: {error}", directory.display()));
for entry in entries {
let path = entry
.unwrap_or_else(|error| {
panic!("read entry in {}: {error}", directory.display())
})
.path();
if path.is_dir() {
directories.push(path);
} else if path
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
{
let relative = path
.strip_prefix(&root)
.expect("mission path is under corpus root")
.to_str()
.expect("mission path is UTF-8")
.replace('\\', "/");
missions.push(relative);
}
}
}
missions.sort();
assert_eq!(
missions.len(),
expected_missions,
"installed mission count for {variable}"
);
let vfs: Arc<dyn fparkan_vfs::Vfs> = Arc::new(DirectoryVfs::new(&root));
let mut prepared = 0usize;
let mut environment_missions = 0usize;
for mission in missions {
let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
let loaded = load_mission_static_preview_roots(
&mut engine,
MissionRequest {
key: mission.clone(),
mode: MissionMode::SingleMission,
},
NonZeroUsize::MAX,
)
.unwrap_or_else(|error| panic!("prepare runtime mission {mission}: {error}"));
let assets = loaded_mission_assets(&engine).expect("mission assets");
let terrain = loaded_terrain(&engine).expect("mission terrain");
let roots = loaded_mission_object_drafts(&engine).expect("mission object drafts");
let preview = static_preview_mesh_and_materials(
assets,
terrain,
roots,
None,
None,
None,
&root,
&loaded.land_msh_path,
)
.unwrap_or_else(|error| panic!("prepare renderer mission {mission}: {error}"));
assert!(!preview.mesh.vertices.is_empty(), "{mission}: empty mesh");
assert!(
!preview.materials.is_empty(),
"{mission}: empty material set"
);
if load_environment(&root, &mission, None, false)
.unwrap_or_else(|error| panic!("prepare environment mission {mission}: {error}"))
.is_some()
{
environment_missions += 1;
}
// `GameAudio::new` resolves and decodes every sample declared by
// mission.cfg before opening the OS device. A headless CI host is
// therefore still a useful resource check: only the final device
// step may be unavailable.
match audio::GameAudio::new(vfs.clone(), &mission) {
Ok(mut audio) => audio.shutdown(),
Err(audio::AudioError::DeviceUnavailable { .. }) => {}
Err(error) => panic!("prepare audio mission {mission}: {error}"),
}
prepared += 1;
}
assert_eq!(prepared, expected_missions);
assert_eq!(environment_missions, expected_missions);
}
#[test]
#[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"]
fn gog_terrain_detail_uses_multitexture_animation_block() {
let root = std::env::var_os("FPARKAN_GAME_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
let mut loader = StandaloneWearMaterialLoader::new(&root);
let mut mesh = VulkanStaticMesh::smoke_triangle();
mesh.draw_ranges[0].material_index = 0x0102;
let mut materials = Vec::new();
let mut animations = Vec::new();
let selections = static_preview_terrain_base_materials(
&mut loader,
&root,
"DATA/MAPS/Tut_2/Land.msh",
&mesh,
&mut materials,
&mut animations,
None,
)
.expect("prepare real terrain material layers");
let binding = &mut animations[0];
let sample = binding.detail.as_mut().unwrap().sample(0, 2000).unwrap();
assert_eq!(
sample.texture_raw.split(|&byte| byte == 0).next().unwrap(),
b"L09M.0"
);
let selected = &materials[usize::from(selections[0].1)];
let path = root.join("DATA/MAPS/Tut_2/Land2.wea");
let (detail, _) = load_standalone_vulkan_texture(&mut loader, &path, 2, 1).unwrap();
let (overlay_detail, _) = load_standalone_vulkan_texture(&mut loader, &path, 1, 1).unwrap();
assert!(
selected.detail_texture.as_ref() == Some(&detail),
"initial detail descriptor must use the multitexture phase"
);
assert!(
selected.overlay_detail_texture.as_ref() == Some(&overlay_detail),
"initial overlay-detail descriptor must use the multitexture phase"
);
assert!(
sample_material_animations(&mut animations, &materials, 2000)
.unwrap()
.stage_phases
.is_empty()
);
}
#[test]
fn draw_range_reorder_remaps_all_material_animation_bindings() {
let range = |first_index, material_index| VulkanStaticDrawRange {
first_index,
index_count: 3,
material_index,
lightmap_index: u8::MAX,
batch_flags: 0,
pipeline_state: world_pipeline_state(
LegacyBlendMode::Opaque,
LegacyDepthMode::TestReadOnly,
),
terrain_base_only: false,
alpha_test_reference: 0,
};
let mut mesh = VulkanStaticMesh {
vertices: Vec::new(),
indices: (0..9).collect(),
draw_ranges: vec![range(0, 10), range(3, 20), range(6, 30)],
};
let empty_binding = || MaterialPhaseBinding {
document: Mat0Document {
version: 0,
animation_block_count: 0,
phases: Vec::new(),
prefix: Vec::new(),
header_opaque: [0; 2],
animation_blocks: Vec::new(),
},
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: 0,
frozen_phase_index: Some(0),
phase_uv_transforms: Vec::new(),
};
let mut bindings = vec![MaterialAnimationBinding {
initial_phase_index: 0,
normalized_phase_override: None,
ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices: vec![10],
range_indices: vec![0, 2],
pipeline_category: 0,
}],
base: empty_binding(),
detail: None,
overlay: None,
overlay_detail: None,
stage_textures: None,
}];
let remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("reorder");
remap_material_animation_ranges(&mut bindings, &remap).expect("binding remap");
assert_eq!(
mesh.indices,
(6..9).chain(0..3).chain(3..6).collect::<Vec<_>>()
);
assert_eq!(mesh.draw_ranges[0].material_index, 30);
assert_eq!(mesh.draw_ranges[1].material_index, 10);
assert_eq!(mesh.draw_ranges[2].material_index, 20);
assert_eq!(bindings[0].variants[0].range_indices, vec![1, 0]);
}
#[test]
fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() {
let range = |blend, batch_flags| VulkanStaticDrawRange {
first_index: 0,
index_count: 3,
material_index: 0,
lightmap_index: u8::MAX,
batch_flags,
pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly),
terrain_base_only: false,
alpha_test_reference: 0,
};
// Transparency is driven by the native predicate. The pipeline blend
// enum is an independent rendering detail and does not qualify an
// opaque range on its own.
assert!(!native_world_transparent(
&range(LegacyBlendMode::SourceAlpha, 0),
1.0
));
assert!(native_world_transparent(
&range(LegacyBlendMode::Opaque, 0),
0.999
));
assert!(native_world_transparent(
&range(LegacyBlendMode::Opaque, 0x100),
1.0
));
assert!(native_world_transparent(
&range(LegacyBlendMode::Opaque, 0x8),
1.0
));
}
#[test]
fn cpu_msh_portal_alpha_updates_with_distance_and_preserves_type16() {
let mut mesh = VulkanStaticMesh::smoke_triangle();
for flags in [0x0148, 0x0108, 0x0118, 0x0058] {
mesh.draw_ranges[0].batch_flags = flags;
assert_eq!(
native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -1.0], 1.04),
Ok(Some(0.0))
);
assert_eq!(
native_msh_diffuse_alpha_override(&mesh, 0, true, [0.0, -0.55, -1.0], 1.04),
Ok(None)
);
}
mesh.draw_ranges[0].batch_flags = 0x0118;
let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04)
.unwrap()
.unwrap();
assert!((alpha - 0.5).abs() < 0.000_001);
assert_eq!(
native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04),
Ok(Some(1.0))
);
assert!(native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 0.0).is_err());
mesh.draw_ranges[0].batch_flags = 0x0040;
assert_eq!(
native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 1.04),
Ok(None)
);
}
#[test]
fn material_animation_selects_pipeline_category_with_active_phase() {
let variant = MaterialVariantSet {
phase_material_indices: vec![10, 11],
range_indices: vec![3],
pipeline_category: 1,
};
let category_for = |material_index| match material_index {
10 => Some(1),
11 => Some(2),
_ => None,
};
assert_eq!(
selected_material_phase(&variant, 0, category_for),
Ok((10, 1))
);
assert_eq!(
selected_material_phase(&variant, 1, category_for),
Ok((11, 2))
);
assert!(selected_material_phase(&variant, 2, category_for).is_err());
}
#[test]
fn native_shadow_kind_mapping_keeps_sun_eligibility_separate_from_caster_defaults() {
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3));
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4));
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10));
assert!(!native_shadow_caster_enabled(3));
assert!(native_shadow_caster_enabled(4));
assert!(!native_shadow_caster_enabled(10));
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT)));
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU)));
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT)));
assert!(!native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_EXTO)));
assert!(!native_sun_object_kind_eligible(None));
}
fn test_shadow_caster(id: u32, center: [f32; 3]) -> ShadowCaster {
ShadowCaster {
id,
world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
sphere: ShadowSphere {
center,
radius: 0.0,
},
native_kind: 4,
triangles: Vec::new(),
}
}
#[test]
fn shadow_frustum_filters_before_the_native_twenty_caster_budget() {
let casters = [
test_shadow_caster(1, [202.0, 0.0, 0.0]),
test_shadow_caster(2, [0.0, 0.0, 0.0]),
];
let visible =
native_visible_shadow_casters(&casters, VulkanStaticCamera::default().clip_from_world);
assert_eq!(
visible.iter().map(|caster| caster.id).collect::<Vec<_>>(),
[2]
);
}
#[test]
fn shadow_frustum_keeps_the_native_hundred_unit_sphere_margin() {
let matrix = VulkanStaticCamera::default().clip_from_world;
assert!(native_shadow_caster_visible(
&test_shadow_caster(1, [100.99, 0.0, 0.0]),
matrix
));
assert!(!native_shadow_caster_visible(
&test_shadow_caster(2, [101.0, 0.0, 0.0]),
matrix
));
}
#[test]
fn projected_shadow_alone_uses_decal_depth_bias() {
let shadow = projected_shadow_pipeline_state();
assert_eq!(shadow.blend, LegacyBlendMode::SourceAlpha);
assert_eq!(shadow.depth, LegacyDepthMode::TestReadOnly);
assert_eq!(shadow.depth_bias, LegacyDepthBias::ProjectedDecal);
assert_eq!(
world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::TestWrite).depth_bias,
LegacyDepthBias::Disabled
);
}
#[test]
fn shadow_atlas_texture_equality_includes_pixels_and_mip_layout() {
let uploaded = shadow_atlas_texture();
assert_eq!(shadow_atlas_texture(), uploaded);
let mut changed_rgba = uploaded.clone();
changed_rgba.rgba8[0] ^= 1;
assert_ne!(changed_rgba, uploaded);
let mut changed_mip_pixels = uploaded.clone();
changed_mip_pixels.mip_levels[0].rgba8[0] ^= 1;
assert_ne!(changed_mip_pixels, uploaded);
let mut changed_mip = uploaded.clone();
changed_mip.mip_levels[0].width -= 1;
assert_ne!(changed_mip, uploaded);
}
#[test]
fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() {
let parent = VulkanNodePose {
translation: [10.0, 20.0, 30.0],
rotation: [0.0, 0.0, 0.0, 1.0],
};
let node1 = VulkanNodePose {
// Native bounds replace this translation with the accumulated
// parent translation while retaining node 1's rotation.
translation: [99.0, -77.0, 42.0],
rotation: [0.0, 0.0, (0.5_f32).sqrt(), (0.5_f32).sqrt()],
};
let first = mounted_shadow_sphere([2.0, 0.0, 0.0], 2.0, Some(parent), Some(node1))
.expect("finite mounted sphere");
assert!((first.center[0] - 10.0).abs() < 1.0e-5);
assert!((first.center[1] - 22.0).abs() < 1.0e-5);
assert!((first.center[2] - 30.0).abs() < 1.0e-5);
let second = ShadowComponentSphere {
center: [12.0, 22.0, 30.0],
radius: 1.0,
};
let caster = aggregate_shadow_caster(
7,
4,
LegacyIron3dEulerTransform {
translation: [1.0, 2.0, 3.0],
orientation_radians: [0.0; 3],
},
[2.0, 3.0, 4.0],
vec![shadow_triangle(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
0,
)],
&[first, second],
)
.expect("aggregate caster");
let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-4);
nearly_equal(caster.sphere.center[0], 22.333_334);
nearly_equal(caster.sphere.center[1], 68.0);
nearly_equal(caster.sphere.center[2], 123.0);
nearly_equal(caster.sphere.radius, 10.666_667);
}
#[test]
fn internal_components_do_not_enter_msh_socket_mount_path() {
assert!(unit_component_is_visible(0, None));
assert!(!unit_component_is_visible(1, None));
assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO)));
}
#[test]
fn parses_required_args() {
assert_eq!(
Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--frames",
"3",
])),
Ok(Args {
root: PathBuf::from("testdata/IS"),
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
frames: 3,
atmosphere_seconds: None,
control_clock_ms: None,
validation: false,
readback_out: None,
readback_sequence_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
static_animation_frame: None,
static_material_phase: None,
})
);
}
#[test]
fn defaults_to_interactive_frame_loop() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
]))
.expect("default interactive arguments");
assert_eq!(parsed.frames, 0);
assert_eq!(parsed.preview_roots, NonZeroUsize::MAX);
assert_eq!(parsed.static_animation_frame, None);
assert_eq!(parsed.control_clock_ms, None);
}
#[test]
fn control_clock_replays_one_shared_clock_from_zero_to_target() {
assert_eq!(control_clock_ms_for_frame(500, 0, 101), 0);
assert_eq!(control_clock_ms_for_frame(500, 50, 101), 250);
assert_eq!(control_clock_ms_for_frame(500, 100, 101), 500);
assert_eq!(control_clock_ms_for_frame(500, 1000, 101), 500);
assert_eq!(control_clock_ms_for_frame(500, 0, 1), 500);
assert_eq!(control_clock_mode_label(Some(0)), "fixed-0ms");
assert_eq!(control_clock_mode_label(Some(500)), "ramp-0-to-500ms");
assert_eq!(control_clock_mode_label(None), "realtime");
}
fn clock_test_app(
control_clock_target_ms: Option<u32>,
) -> (StaticVulkanApp, ControlEffectInstanceKey) {
let phase = |additive_red| MaterialPhase {
parameters: [
additive_red,
0,
0,
100,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
texture_raw: [0; 16],
};
let document = Mat0Document {
version: 0,
animation_block_count: 1,
phases: vec![phase(0), phase(255)],
prefix: Vec::new(),
header_opaque: [0; 2],
animation_blocks: vec![MaterialAnimationBlock {
header_raw: 0,
keys: vec![
MaterialKey {
k0: 0,
k1: 500,
k2: 0,
},
MaterialKey {
k0: 1,
k1: 1000,
k2: 0,
},
],
bytes: Vec::new(),
}],
};
let material_animation = MaterialAnimationBinding {
initial_phase_index: 0,
normalized_phase_override: None,
ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices: vec![0, 1],
range_indices: vec![0],
pipeline_category: 0,
}],
base: MaterialPhaseBinding {
document,
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: 0,
frozen_phase_index: None,
phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2],
},
detail: None,
overlay: None,
overlay_detail: None,
stage_textures: None,
};
let mut materials = Vec::new();
append_environment_gradient_material(&mut materials)
.expect("first animated material variant");
append_environment_gradient_material(&mut materials)
.expect("second animated material variant");
let key = ControlEffectInstanceKey {
object_index: 0,
component_index: 0,
table_index: 0,
row_index: 0,
};
let control_slot = ControlEffectSlot {
time_mode: 1,
header_flags: 9,
random_instance_key: key,
random_seed: 0x1234_5678,
phase_jitter: 0.25,
random_shift: [2.0, 3.0, 4.0],
duration_seconds: 1.0,
owner_object_index: 0,
owner_component_index: 0,
owner_node_index: 0,
owner_component_is_mounted: false,
root_transform: LegacyIron3dEulerTransform {
translation: [10.0, 20.0, 30.0],
orientation_radians: [0.0; 3],
},
owner_scale: [1.0; 3],
mount_pose: None,
local_from_effect: [
[1.0, 0.0, 0.0, 1.0],
[0.0, 1.0, 0.0, 2.0],
[0.0, 0.0, 1.0, 3.0],
[0.0, 0.0, 0.0, 1.0],
],
world_from_effect: [[0.0; 4]; 4],
command_index: 0,
command: endpoint_op1_command(6, 0.5, 1.0),
};
let sky_materials = SkyMaterials::default();
let sky =
SkySystem::new(Default::default(), sky_materials.clone()).expect("test sky topology");
let environment = DynamicEnvironment {
schedule: TypedAtmosphere {
marker: 0,
version: 0,
tracks: Vec::new(),
trailer: Default::default(),
},
sky,
environment: EnvironmentSystem::new(0x4650_4152_4B41_4E),
materials: sky_materials,
material_assets: HashMap::new(),
schedule_offset_seconds: 0.0,
time_seconds: 0.0,
fixed_schedule_seconds: None,
fixed_phase_reported: false,
fixed_sky_uv_ms: None,
};
let mut land = fparkan_terrain_format::LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: fparkan_terrain_format::TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
positions: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]],
normals: Vec::new(),
uv0: Vec::new(),
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: vec![fparkan_terrain_format::TerrainFace28 {
flags: FullSurfaceMask(1),
material_tag: 0,
aux_tag: 0,
vertices: [0, 1, 2],
neighbors: [None, None, None],
tail_raw: [0; 8],
raw: [0; 28],
}],
};
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let terrain =
Arc::new(TerrainWorld::from_land_msh(&land).expect("clock test LOD0 terrain"));
let free_camera = FreeFlightCamera {
position: [2.0, 2.0, 5.0],
yaw: 0.25,
pitch: -0.1,
horizontal_fov: 1.3,
near_plane: 0.5,
far_plane: 700.0,
move_speed: 4.0,
ground_clearance: 0.0,
};
let view_camera = free_camera.preview_frame(16.0 / 9.0);
let preview = StaticPreviewScene {
terrain_lod: None,
dynamic_vertex_start: 3,
mesh: VulkanStaticMesh {
vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
.into_iter()
.map(|position| VulkanStaticVertex {
position,
color: [1.0; 3],
normal: [0.0, 0.0, 1.0],
uv: [0.0; 2],
detail_uv: [0.0; 2],
overlay_alpha: 0.0,
})
.collect(),
indices: vec![0, 1, 2],
draw_ranges: vec![VulkanStaticDrawRange {
first_index: 0,
index_count: 3,
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
terrain_base_only: false,
pipeline_state: world_pipeline_state(
LegacyBlendMode::Opaque,
LegacyDepthMode::TestReadOnly,
),
alpha_test_reference: 0,
}],
},
native_handedness_by_range: HashMap::new(),
type16_present_ranges: HashSet::new(),
materials,
material_animations: vec![material_animation],
ambient_effect_slots: Vec::new(),
control_point_light_slots: vec![control_slot],
control_sound_slots: Vec::new(),
unsupported_control_sounds: 0,
component_pose_buffers: vec![vec![None]],
component_mount_poses: vec![vec![None]],
dynamic_component_geometry: Vec::new(),
building_collision_bindings: Vec::new(),
control_animation_instances: Vec::new(),
dynamic_shadow_casters: Vec::new(),
sun_occlusion_range_indices: Vec::new(),
shadow_casters: Vec::new(),
shadow_receivers: Vec::new(),
shadow_material_index: 0,
terrain: terrain.clone(),
camera: view_camera.vulkan_camera(),
view_camera,
captured_viewport: None,
free_camera: Some(free_camera),
camera_mode: "test",
mesh_components: 0,
terrain_components: 0,
};
let app = StaticVulkanApp::new(
preview,
Some(environment),
None,
EnvironmentGpuRange {
range_index: 1,
vertex_start: 3,
vertex_capacity: 0,
material_index: 0,
},
3,
3,
None,
2,
"clock-test",
1,
None,
None,
false,
control_clock_target_ms,
StartupTimings::default(),
);
(app, key)
}
fn control_random_snapshot(
state: &ControlEffectRandomState,
) -> (
u32,
u32,
ControlEffectRandomSample,
Option<SceneSampleId>,
Option<u32>,
) {
(
state.phase_and_z_rng.state,
state.xy_rng.state,
state.sample,
state.last_update_id,
state.last_update_clock_ms,
)
}
#[test]
fn live_clock_samples_have_increasing_ids_and_monotonic_time() {
let (mut app, _) = clock_test_app(None);
let first = app.sample_frame_clock();
let second = app.sample_frame_clock();
assert_eq!(first.id, SceneSampleId::Live(0));
assert_eq!(second.id, SceneSampleId::Live(1));
assert!(second.time_ms >= first.time_ms);
assert!(second.delta_seconds >= 0.0);
}
#[test]
fn zero_extent_resume_does_not_add_minimized_time_to_live_clock() {
let (mut app, _) = clock_test_app(None);
let started = Instant::now();
app.simulation_last_tick = started;
app.pause_live_clock_at(started);
assert!(app.live_clock_paused_for_zero_extent);
let restored = started + Duration::from_secs(600);
app.resume_live_clock_at(restored);
assert!(!app.live_clock_paused_for_zero_extent);
assert_eq!(app.simulation_last_tick, restored);
let resumed = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
assert_eq!(resumed.id, SceneSampleId::Live(0));
assert_eq!(resumed.time_ms, 16);
assert!((resumed.delta_seconds - 0.016).abs() < 1.0e-6);
assert_eq!(app.simulation_elapsed, Duration::from_millis(16));
}
#[test]
fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() {
let (mut app, key) = clock_test_app(None);
app.render_scene_update()
.expect("an early redraw without a packet is a no-op");
assert!(app.pending_scene_update.is_none());
assert_eq!(app.frames_presented, 0);
assert_eq!(app.simulation_update_generation, 0);
let mut first = app.sample_frame_clock();
assert_eq!(first.id, SceneSampleId::Live(0));
first.time_ms = 0;
first.delta_seconds = 0.1;
app.prepare_scene_update(first, [1280.0, 720.0])
.expect("prepare a live CPU frame without a renderer");
assert!(app.renderer.is_none());
let first_frame = app.pending_scene_update.as_ref().expect("sampled frame");
assert_eq!(
first_frame.material_update.active_materials[&0].phase_index,
0
);
assert!(!first_frame
.environment
.as_ref()
.unwrap()
.sky_mesh
.vertices
.is_empty());
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
assert_eq!(first_random.3, Some(SceneSampleId::Live(0)));
assert_ne!(first_random.0, 0);
assert_ne!(first_random.1, 0);
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
assert!(first_environment_time > 0.0);
let first_vertices = app.mesh.vertices.clone();
for _ in 0..2 {
app.render_scene_update()
.expect("renderer-free redraw does not sample simulation state");
}
assert_eq!(
control_random_snapshot(&app.control_effect_random_states[&key]),
first_random
);
assert_eq!(
app.environment.as_ref().unwrap().time_seconds,
first_environment_time
);
assert_eq!(app.mesh.vertices, first_vertices);
assert_eq!(app.simulation_update_generation, 1);
let mut next = app.sample_frame_clock();
assert_eq!(next.id, SceneSampleId::Live(1));
next.time_ms = 500;
next.delta_seconds = 0.5;
app.prepare_scene_update(next, [1280.0, 720.0])
.expect("replace the latest live packet");
assert_eq!(app.simulation_update_generation, 2);
assert_eq!(
app.pending_scene_update.as_ref().unwrap().clock.id,
SceneSampleId::Live(1)
);
assert_ne!(
control_random_snapshot(&app.control_effect_random_states[&key]),
first_random
);
assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time);
assert_eq!(
app.pending_scene_update
.as_ref()
.unwrap()
.material_update
.active_materials[&0]
.phase_index,
1
);
assert_eq!(
app.pending_scene_update.as_ref().unwrap().clock.time_ms,
500
);
assert_eq!(
app.pending_scene_update
.as_ref()
.unwrap()
.environment
.as_ref()
.unwrap()
.sky_mesh
.vertices[0]
.uv[2],
[0.01, 0.0]
);
app.environment.as_mut().unwrap().fixed_sky_uv_ms = Some(25_000);
next.id = SceneSampleId::Live(2);
next.time_ms = 10_000;
app.prepare_scene_update(next, [1280.0, 720.0]).unwrap();
assert_eq!(
app.pending_scene_update
.as_ref()
.unwrap()
.environment
.as_ref()
.unwrap()
.sky_mesh
.vertices[0]
.uv[2],
[0.5, 0.0]
);
}
#[test]
fn live_pending_resize_reuses_225ms_packet_across_zero_extent_pause() {
let (mut app, key) = clock_test_app(None);
let started = Instant::now();
app.simulation_last_tick = started;
app.pressed_keys.insert(KeyCode::KeyE);
let first = app.sample_live_frame_clock_at(started + Duration::from_millis(225));
assert_eq!(first.id, SceneSampleId::Live(0));
assert_eq!(first.time_ms, 225);
assert!((first.delta_seconds - 0.225).abs() < 1.0e-6);
app.prepare_scene_update(first, [1280.0, 720.0])
.expect("sample one 225 ms live packet");
let first_frame = app.pending_scene_update.as_ref().unwrap();
let first_camera = first_frame.camera;
let expected_resized_camera = app
.free_camera
.expect("free-flight input controller")
.preview_frame(4.0 / 3.0);
let first_effect_updates = first_frame.effect_updates.clone();
let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
let weather = WeatherInput {
rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]),
snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]),
..WeatherInput::clear()
};
let mut weather_system = EnvironmentSystem::new(0xC1_225);
let first_environment_frame = weather_system.update(
0.225,
weather,
environment_camera_for_preview(&first_camera, 16.0 / 9.0, [1280.0, 720.0]),
);
let mut precipitation_counts = [0usize; 2];
for primitive in &first_environment_frame.primitives {
if let EnvironmentPrimitive::Particle { kind, .. } = primitive {
let slot = match kind {
PrecipitationKind::Rain => 0,
PrecipitationKind::Snow => 1,
};
precipitation_counts[slot] += 1;
}
}
assert!(precipitation_counts.iter().all(|count| *count >= 17));
app.pending_scene_update
.as_mut()
.unwrap()
.environment
.as_mut()
.unwrap()
.environment_frame = first_environment_frame.clone();
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
let first_environment_system_time =
app.environment.as_ref().unwrap().environment.time_seconds();
let first_vertices = app.mesh.vertices.clone();
assert!((first_camera.position[2] - 5.4).abs() < 1.0e-5);
assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
assert_eq!(app.simulation_update_generation, 1);
app.pause_live_clock_at(started + Duration::from_millis(250));
let restored = started + Duration::from_secs(10);
app.resume_live_clock_at(restored);
app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0);
let controller_after_input = app.free_camera.unwrap();
let retry = app.pending_or_sample_frame_clock();
assert_eq!(retry, first);
assert_eq!(app.simulation_update_generation, 1);
assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
assert_eq!(app.simulation_last_tick, restored);
app.prepare_scene_update(retry, [1024.0, 768.0])
.expect("refresh only the pending packet's viewport");
let retry_frame = app.pending_scene_update.as_ref().unwrap();
assert_eq!(retry_frame.clock, first);
assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
assert_eq!(retry_frame.aspect, 4.0 / 3.0);
assert_eq!(retry_frame.camera.position, first_camera.position);
assert_eq!(retry_frame.camera.forward, first_camera.forward);
assert_eq!(retry_frame.camera.right, first_camera.right);
assert_eq!(retry_frame.camera.up, first_camera.up);
assert_eq!(
retry_frame.camera.position,
expected_resized_camera.position
);
assert_eq!(retry_frame.camera.forward, expected_resized_camera.forward);
assert_eq!(retry_frame.camera.right, expected_resized_camera.right);
assert_eq!(retry_frame.camera.up, expected_resized_camera.up);
assert!(
(retry_frame.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6
);
assert!(retry_frame
.camera
.clip_from_world
.iter()
.zip(expected_resized_camera.clip_from_world)
.all(|(actual, expected)| (actual - expected).abs() < 1.0e-5));
assert_ne!(
retry_frame.camera.clip_from_world,
first_camera.clip_from_world
);
assert_eq!(app.free_camera, Some(controller_after_input));
assert_eq!(retry_frame.effect_updates, first_effect_updates);
assert_eq!(
retry_frame.material_update.active_materials[&0].phase_index,
first_material_phase
);
let retry_environment_frame = &retry_frame.environment.as_ref().unwrap().environment_frame;
assert_eq!(
retry_environment_frame.point_lights,
first_environment_frame.point_lights
);
assert_eq!(
retry_environment_frame.sounds,
first_environment_frame.sounds
);
let mut reprojected_counts = [0usize; 2];
for (sampled, refreshed) in first_environment_frame
.primitives
.iter()
.zip(&retry_environment_frame.primitives)
{
let (
EnvironmentPrimitive::Particle {
kind: sampled_kind,
position: sampled_position,
velocity: sampled_velocity,
size: sampled_size,
screen: sampled_screen,
..
},
EnvironmentPrimitive::Particle {
kind,
position,
velocity,
size,
screen,
..
},
) = (sampled, refreshed)
else {
panic!("rain and snow fixture contains only precipitation particles");
};
assert_eq!(kind, sampled_kind);
assert_eq!(position, sampled_position);
assert_eq!(velocity, sampled_velocity);
assert_ne!(screen, sampled_screen);
let slot = match kind {
PrecipitationKind::Rain => 0,
PrecipitationKind::Snow => 1,
};
reprojected_counts[slot] += 1;
assert_ne!(size, sampled_size);
}
assert!(reprojected_counts.iter().all(|count| *count >= 17));
assert_eq!(
control_random_snapshot(&app.control_effect_random_states[&key]),
first_random
);
assert_eq!(
app.environment.as_ref().unwrap().time_seconds,
first_environment_time
);
assert_eq!(
app.environment.as_ref().unwrap().environment.time_seconds(),
first_environment_system_time
);
assert_eq!(app.mesh.vertices, first_vertices);
assert_eq!(app.record_presented_scene_frame(), 0);
let next = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
assert_eq!(next.id, SceneSampleId::Live(1));
assert_eq!(next.time_ms, 241);
assert!((next.delta_seconds - 0.016).abs() < 1.0e-6);
assert_eq!(app.simulation_elapsed, Duration::from_millis(241));
app.prepare_scene_update(next, [1024.0, 768.0])
.expect("the next sample adopts mouse input accumulated during retry");
let next_camera = app.pending_scene_update.as_ref().unwrap().camera;
assert_ne!(next_camera.forward, first_camera.forward);
assert_eq!(next_camera.forward, controller_after_input.forward());
}
#[test]
fn replay_retries_keep_one_packet_until_presented_then_advance_with_new_viewport() {
let (mut app, key) = clock_test_app(Some(500));
app.render_scene_update()
.expect("redraw without a pending packet does nothing");
assert_eq!(app.simulation_update_generation, 0);
let first = app.sample_frame_clock();
assert_eq!(
first,
FrameClockSample {
id: SceneSampleId::Replay(0),
time_ms: 0,
delta_seconds: 0.0,
}
);
app.prepare_scene_update(first, [1280.0, 720.0])
.expect("prepare the first replay frame");
let first_frame = app.pending_scene_update.as_ref().unwrap();
let first_camera = first_frame.camera;
let first_viewport = first_frame.viewport;
let first_aspect = first_frame.aspect;
let first_effect_updates = first_frame.effect_updates.clone();
let first_environment_mesh = first_frame.environment.as_ref().unwrap().sky_mesh.clone();
let first_environment_frame = first_frame
.environment
.as_ref()
.unwrap()
.environment_frame
.clone();
let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
let first_vertices = app.mesh.vertices.clone();
assert_eq!(first_material_phase, 0);
assert_eq!(first_random.3, Some(SceneSampleId::Replay(0)));
assert_eq!(first_random.4, Some(0));
assert!(!first_environment_mesh.vertices.is_empty());
for _ in 0..2 {
app.render_scene_update()
.expect("renderer-free replay redraw is a retry");
let retry = app.sample_frame_clock();
assert_eq!(retry.id, SceneSampleId::Replay(0));
app.prepare_scene_update(retry, [1024.0, 768.0])
.expect("a resize retry reuses the current replay packet");
}
let retry_frame = app.pending_scene_update.as_ref().unwrap();
assert_eq!(retry_frame.camera.position, first_camera.position);
assert_eq!(retry_frame.camera.forward, first_camera.forward);
assert_eq!(retry_frame.camera.up, first_camera.up);
assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
assert_eq!(retry_frame.aspect, 4.0 / 3.0);
assert_ne!(
retry_frame.camera.clip_from_world,
first_camera.clip_from_world
);
assert_ne!(retry_frame.viewport, first_viewport);
assert_ne!(retry_frame.aspect, first_aspect);
assert_eq!(retry_frame.effect_updates, first_effect_updates);
assert_eq!(
retry_frame.environment.as_ref().unwrap().sky_mesh,
first_environment_mesh
);
assert_eq!(
retry_frame.environment.as_ref().unwrap().environment_frame,
first_environment_frame
);
assert_eq!(
retry_frame.material_update.active_materials[&0].phase_index,
0
);
assert_eq!(
control_random_snapshot(&app.control_effect_random_states[&key]),
first_random
);
assert_eq!(
app.environment.as_ref().unwrap().time_seconds,
first_environment_time
);
assert_eq!(app.mesh.vertices, first_vertices);
assert_eq!(app.frames_presented, 0);
assert_eq!(app.record_presented_scene_frame(), 0);
assert_eq!(app.frames_presented, 1);
assert!(app.pending_scene_update.is_none());
let next = app.sample_frame_clock();
assert_eq!(next.id, SceneSampleId::Replay(1));
assert_eq!(next.time_ms, 500);
assert_eq!(next.delta_seconds, 0.5);
app.prepare_scene_update(next, [1024.0, 768.0])
.expect("the next presented replay frame samples the resized view");
let next_frame = app.pending_scene_update.as_ref().unwrap();
assert_eq!(next_frame.viewport, [1024.0, 768.0]);
assert_eq!(next_frame.aspect, 4.0 / 3.0);
assert_ne!(
next_frame.camera.clip_from_world,
first_camera.clip_from_world
);
assert_eq!(
next_frame.material_update.active_materials[&0].phase_index,
1
);
assert!(
(app.environment.as_ref().unwrap().time_seconds - first_environment_time - 0.5).abs()
< 1.0e-6
);
let next_random = control_random_snapshot(&app.control_effect_random_states[&key]);
assert_eq!(next_random.3, Some(SceneSampleId::Replay(1)));
assert_eq!(next_random.4, Some(500));
assert_ne!(next_random, first_random);
let next_camera = next_frame.camera;
let next_environment_mesh = next_frame.environment.as_ref().unwrap().sky_mesh.clone();
let next_environment_frame = next_frame
.environment
.as_ref()
.unwrap()
.environment_frame
.clone();
app.render_scene_update()
.expect("renderer-free draw does not advance the replay packet");
let repeated = app.sample_frame_clock();
assert_eq!(repeated.id, SceneSampleId::Replay(1));
app.prepare_scene_update(repeated, [800.0, 600.0])
.expect("duplicate replay frame is idempotent");
let repeated_frame = app.pending_scene_update.as_ref().unwrap();
assert_eq!(repeated_frame.viewport, [800.0, 600.0]);
assert_eq!(repeated_frame.aspect, 4.0 / 3.0);
assert_eq!(repeated_frame.camera, next_camera);
assert_eq!(
repeated_frame.environment.as_ref().unwrap().sky_mesh,
next_environment_mesh
);
assert_eq!(
repeated_frame
.environment
.as_ref()
.unwrap()
.environment_frame,
next_environment_frame
);
assert_eq!(
control_random_snapshot(&app.control_effect_random_states[&key]),
next_random
);
assert_eq!(
app.environment.as_ref().unwrap().time_seconds,
first_environment_time + 0.5
);
assert_eq!(app.frames_presented, 1);
}
#[test]
fn replay_environment_delta_uses_last_applied_clock_and_keeps_duplicates_at_zero() {
let initial = control_clock_ms_for_frame(500, 0, 101);
assert_eq!(initial, 0);
assert_eq!(replay_environment_delta_seconds(0, initial), 0.0);
let first = control_clock_ms_for_frame(500, 1, 101);
let repeated = control_clock_ms_for_frame(500, 1, 101);
assert_eq!(replay_environment_delta_seconds(initial, first), 0.005);
assert_eq!(replay_environment_delta_seconds(first, repeated), 0.0);
let next = control_clock_ms_for_frame(500, 2, 101);
assert_eq!(replay_environment_delta_seconds(first, next), 0.005);
assert_eq!(
replay_environment_delta_seconds(0, control_clock_ms_for_frame(500, 0, 1)),
0.5
);
assert_eq!(replay_environment_delta_seconds(next, 0), 0.0);
}
#[test]
fn ambient_effect_screen_bounds_follow_vulkan_viewport_y_direction() {
let vertices = [
[-0.5, 0.25, 0.5],
[-0.5, 0.75, 0.5],
[0.5, 0.75, 0.5],
[0.5, 0.25, 0.5],
];
let bounds = ambient_effect_projected_bounds(
&vertices,
[
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
[100, 100],
)
.expect("visible clip-space quad");
assert_eq!(bounds, [25.0, 62.5, 75.0, 87.5]);
}
#[test]
fn native_tl_sprite_pixels_map_to_current_vulkan_viewport_at_z_999() {
let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
let small = screen_tl_quad_vertices_pixels(
[160.0, 120.0],
40.0,
30.0,
[640.0, 480.0],
[1.0; 3],
1.0,
uv,
);
let resized = screen_tl_quad_vertices_pixels(
[320.0, 240.0],
80.0,
60.0,
[1280.0, 960.0],
[1.0; 3],
1.0,
uv,
);
for (small, resized) in small.iter().zip(resized) {
for axis in 0..2 {
assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6);
}
assert_eq!(small.position[2], 0.999);
assert_eq!(resized.position[2], 0.999);
}
for (actual, expected) in [
(small[0].position, [-0.625, -0.375, 0.999]),
(small[2].position, [-0.625, -0.625, 0.999]),
] {
for axis in 0..3 {
assert!((actual[axis] - expected[axis]).abs() < 1.0e-6);
}
}
assert_eq!(small[0].uv, [0.0, 1.0]);
assert_eq!(small[2].uv, [0.0, 0.0]);
}
#[test]
fn parses_control_clock_capture() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--frames",
"101",
"--control-clock-ms",
"500",
]))
.expect("controlled clock capture arguments");
assert_eq!(parsed.control_clock_ms, Some(500));
assert!(Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--control-clock-ms",
"500",
]))
.expect_err("replay needs multiple frames")
.contains("at least 2"));
}
#[test]
fn lightmap_ranges_stay_on_the_decorated_variant_when_phase_changes() {
let empty_mat0 = Mat0Document {
version: 0,
animation_block_count: 0,
phases: Vec::new(),
prefix: Vec::new(),
header_opaque: [0; 2],
animation_blocks: Vec::new(),
};
let mut bindings = vec![MaterialAnimationBinding {
initial_phase_index: 0,
normalized_phase_override: None,
ambient_fx_material_alpha: false,
variants: vec![MaterialVariantSet {
phase_material_indices: vec![10, 11],
range_indices: Vec::new(),
pipeline_category: 0,
}],
base: MaterialPhaseBinding {
document: empty_mat0,
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: 0,
frozen_phase_index: Some(0),
phase_uv_transforms: Vec::new(),
},
detail: None,
overlay: None,
overlay_detail: None,
stage_textures: None,
}];
let (binding_index, source_phases) =
find_material_animation_variant(&bindings, 10).expect("base material variant");
assert_eq!(source_phases, vec![10, 11]);
bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: vec![20, 21],
range_indices: vec![7],
pipeline_category: 0,
});
let lightmap_variant = &bindings[binding_index].variants[1];
assert_eq!(lightmap_variant.range_indices, vec![7]);
assert_eq!(
selected_material_variant_index(lightmap_variant, 0),
Some(20)
);
assert_eq!(
selected_material_variant_index(lightmap_variant, 1),
Some(21)
);
}
fn row_vector_transform(point: [f32; 4], matrix: [f32; 16]) -> [f32; 4] {
let mut result = [0.0; 4];
for column in 0..4 {
result[column] = (0..4)
.map(|row| point[row] * matrix[row * 4 + column])
.sum();
}
result
}
#[test]
fn free_flight_camera_uses_right_handed_z_up_basis_and_positive_depth() {
let camera = FreeFlightCamera {
position: [0.0, 0.0, 0.0],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: std::f32::consts::FRAC_PI_2,
near_plane: 1.0,
far_plane: 11.0,
move_speed: 1.0,
ground_clearance: 0.0,
};
let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5);
assert_eq!(camera.forward(), [1.0, 0.0, 0.0]);
assert_eq!(camera.right(), [0.0, -1.0, 0.0]);
assert_eq!(camera.up(), [0.0, 0.0, 1.0]);
let matrix = camera.vulkan_camera(1.0).clip_from_world;
let near = row_vector_transform([1.0, 0.0, 0.0, 1.0], matrix);
let far = row_vector_transform([11.0, 0.0, 0.0, 1.0], matrix);
let camera_right = row_vector_transform([1.0, -1.0, 0.0, 1.0], matrix);
let world_up = row_vector_transform([1.0, 0.0, 1.0, 1.0], matrix);
nearly_equal(near[0], 0.0);
nearly_equal(near[1], 0.0);
nearly_equal(near[2], 0.0);
nearly_equal(near[3], 1.0);
assert!(camera_right[0] / camera_right[3] > 0.0);
assert!(world_up[1] / world_up[3] < 0.0);
nearly_equal(far[2] / far[3], 1.0);
}
#[test]
fn free_flight_camera_uses_native_horizontal_fov_for_every_viewport_aspect() {
let horizontal_fov = 1.3_f32;
let camera =
FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3]).expect("finite free-flight bounds");
assert_eq!(camera.horizontal_fov, horizontal_fov);
assert_eq!(camera.near_plane, 0.5);
assert_eq!(camera.far_plane, 700.0);
let assert_edges = |frame: PreviewCameraFrame, aspect: f32| {
let expected_vertical_fov = 2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan();
assert!((frame.vertical_fov - expected_vertical_fov).abs() < 1.0e-5);
let depth = 10.0;
let center = add3(frame.position, scale3(frame.forward, depth));
let right_edge = add3(
center,
scale3(frame.right, depth * (horizontal_fov * 0.5).tan()),
);
let top_edge = add3(
center,
scale3(frame.up, depth * (expected_vertical_fov * 0.5).tan()),
);
let center_clip = row_vector_transform(
[center[0], center[1], center[2], 1.0],
frame.clip_from_world,
);
let right_clip = row_vector_transform(
[right_edge[0], right_edge[1], right_edge[2], 1.0],
frame.clip_from_world,
);
let top_clip = row_vector_transform(
[top_edge[0], top_edge[1], top_edge[2], 1.0],
frame.clip_from_world,
);
assert!(center_clip[3] > 0.0 && right_clip[3] > 0.0 && top_clip[3] > 0.0);
assert!(
center_clip[0].abs() < 5.0e-4,
"aspect {aspect}: {center_clip:?}"
);
assert!(
center_clip[1].abs() < 5.0e-4,
"aspect {aspect}: {center_clip:?}"
);
assert!(
(right_clip[0] / right_clip[3] - 1.0).abs() < 1.0e-4,
"aspect {aspect}: {right_clip:?}"
);
assert!(
(top_clip[1] / top_clip[3] + 1.0).abs() < 1.0e-4,
"aspect {aspect}: {top_clip:?}"
);
};
for aspect in [0.05, 0.75, 4.0 / 3.0, 16.0 / 9.0, 20.0] {
assert_edges(camera.preview_frame(aspect), aspect);
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1000, (1000.0 / aspect).round() as i32],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: horizontal_fov,
};
let raw_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 captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection)
.expect("valid D3D7 projection");
assert_edges(
captured,
projection.viewport[2] as f32 / projection.viewport[3] as f32,
);
}
}
#[test]
fn direction_projection_accepts_front_offscreen_rays_but_rejects_horizon() {
let camera = FreeFlightCamera {
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.3,
near_plane: 0.5,
far_plane: 700.0,
move_speed: 1.0,
ground_clearance: 0.0,
}
.preview_frame(16.0 / 9.0);
let viewport = [1280.0, 720.0];
let offscreen = project_direction_pixels(&camera, [0.2, 0.979_795_9, 0.0], viewport)
.expect("front-facing directions are not rejected by the near distance");
assert!(offscreen[0] < 0.0 || offscreen[0] > viewport[0]);
assert!(project_direction_pixels(&camera, [1.0e-5, 1.0, 0.0], viewport).is_none());
assert!(project_direction_pixels(&camera, [-1.0, 0.0, 0.0], viewport).is_none());
let ray = screen_ray_from_pixels(&camera, [viewport[0] * 1.1, viewport[1] * 0.5], viewport)
.expect("an offscreen pixel still has a view ray");
let ray_pixels = project_direction_pixels(&camera, ray, viewport)
.expect("the offscreen ray remains in the front hemisphere");
assert!((ray_pixels[0] - viewport[0] * 1.1).abs() < 1.0e-3);
assert!((ray_pixels[1] - viewport[1] * 0.5).abs() < 1.0e-3);
}
#[test]
fn native_sky_heading_selects_matching_gradient_sectors_for_both_camera_origins() {
use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT};
let mut colors = [PackedColor(0xFF00_0000); SKY_COLOR_COUNT];
colors[..4].copy_from_slice(&[
PackedColor(0xFF11_0000),
PackedColor(0xFF00_2200),
PackedColor(0xFF00_0033),
PackedColor(0xFF44_4400),
]);
let sample = SkySample {
colors,
values: [0.0; 2],
packed: [PackedColor(0); 2],
};
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1280, 720],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.596_851_4,
};
let cardinal_forwards: [([f32; 3], usize); 4] = [
([0.0, 1.0, 0.0], 0),
([1.0, 0.0, 0.0], 1),
([0.0, -1.0, 0.0], 2),
([-1.0, 0.0, 0.0], 3),
];
for (forward, sector) in cardinal_forwards {
let free_flight = FreeFlightCamera {
position: [0.0; 3],
yaw: forward[1].atan2(forward[0]),
pitch: 0.0,
horizontal_fov: projection.field_of_view_radians,
near_plane: projection.near_plane,
far_plane: projection.far_plane,
move_speed: 1.0,
ground_clearance: 0.0,
}
.preview_frame(1280.0 / 720.0);
let right = normalize3(cross3(forward, [0.0, 0.0, 1.0])).expect("camera right");
let up = normalize3(cross3(right, forward)).expect("camera up");
let raw_transform = RawCameraTransform {
words: [
forward[0].to_bits(),
(-right[0]).to_bits(),
up[0].to_bits(),
0.0_f32.to_bits(),
forward[1].to_bits(),
(-right[1]).to_bits(),
up[1].to_bits(),
0.0_f32.to_bits(),
forward[2].to_bits(),
(-right[2]).to_bits(),
up[2].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 captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection)
.expect("captured camera frame");
for (origin, camera) in [("free flight", free_flight), ("captured", captured)] {
let gradient = fparkan_fx::sky::screen_gradient_frame(
&sample,
camera.native_sky_heading(),
[0.0; 3],
);
assert_eq!(
gradient.color, sample.colors[sector],
"{origin} camera heading for forward {forward:?}"
);
}
}
}
#[test]
fn captured_native_camera_selects_view_dependent_screen_horizon_color() {
use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT};
// Compact camera fields from the native 1493.337-second AutoDemo capture.
let capture = parse_legacy_camera_capture(
br#"{"schema":"fparkan-legacy-camera-v1","render_input_usable":true,"parented_camera":true,"effective_world_matrix_space":"world","effective_world_matrix_words":[1052770830,1064129158,991881820,1141874407,3211610778,1052772554,3144770972,1145115441,3146624790,3127057462,1065352580,1087889619,0,0,0,1065353216],"selector0_words":[1063123992,3204380634,996787387,3130169600,1056894806,1063125889,990301923,3129510813,3146181515,3070164857,1065352633,1080608413,0,0,0,1065353216],"viewport":[0,0,1280,1024],"near_plane":5.0,"far_plane":700.0,"field_of_view_radians":1.3,"atmosphere_seconds":1493.337}"#,
)
.expect("valid native camera fixture");
let camera = capture.preview_camera_frame().expect("valid camera frame");
let mut colors = [PackedColor(0xFF00_0000); SKY_COLOR_COUNT];
colors[..4].copy_from_slice(&[
PackedColor(0xFF38_3864),
PackedColor(0xFF38_3864),
PackedColor(0xFF40_4064),
PackedColor(0xFF38_3864),
]);
let sample = SkySample {
colors,
values: [0.0; 2],
packed: [PackedColor(0); 2],
};
let horizon_color =
fparkan_fx::sky::screen_gradient_frame(&sample, camera.native_sky_heading(), [0.0; 3])
.color;
assert_eq!(capture.atmosphere_seconds, Some(1493.337));
assert_eq!(horizon_color, PackedColor(0xFF3E_3E64));
}
#[test]
fn sky_normals_keep_native_signed_byte_scale_without_renormalizing() {
assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]);
}
fn set_test_terrain_lod_slots(
terrain: &mut fparkan_terrain_format::LandMeshDocument,
node_slots: &[[u16; 2]],
face_ranges: &[[u16; 2]],
) {
const NODE_STRIDE: usize = 38;
let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE];
for (node_index, [lod0_slot, lod1_slot]) in node_slots.iter().copied().enumerate() {
let offset = node_index * NODE_STRIDE + 8;
nodes[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes());
nodes[offset + 2..offset + 4].copy_from_slice(&lod1_slot.to_le_bytes());
}
let mut slots = vec![[0_u8; 68]; face_ranges.len()];
for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) {
slot[..2].copy_from_slice(&start.to_le_bytes());
slot[2..4].copy_from_slice(&count.to_le_bytes());
}
terrain.nodes_raw = nodes;
terrain.slots.slots_raw = slots;
}
fn mission_root_for_basement(position: [f32; 3], raw_after_name: u32) -> MissionObjectDraft {
MissionObjectDraft {
original_id: None,
resource_name_raw: b"BUILDS/example.dat".to_vec(),
raw_after_resource: 0,
identity_or_clan_raw: 0,
raw_after_name,
unit_dat_header_flags: Some(0x8000_0000),
position,
orientation_raw: [0.0; 3],
scale: [1.0; 3],
visual_ids: Vec::new(),
properties: Vec::new(),
unit_components: Vec::new(),
}
}
#[test]
fn basement_delta_updates_one_common_root_for_mesh_and_attached_component() {
let roots = [
mission_root_for_basement([1.0, 2.0, 100.0], 0),
mission_root_for_basement([3.0, 4.0, 200.0], 1),
];
let footprints = [
MissionBasementFootprint {
object_index: 0,
outer: Vec::new(),
inner: Vec::new(),
flatten_inner_to_mean_outer_z: true,
},
MissionBasementFootprint {
object_index: 1,
outer: Vec::new(),
inner: Vec::new(),
flatten_inner_to_mean_outer_z: false,
},
];
let placements = [
BasementPlacement {
placement_delta: 12.5,
},
BasementPlacement {
placement_delta: 25.0,
},
];
let adjusted = apply_basement_placement_deltas(&roots, &footprints, &placements)
.expect("aligned native placements");
assert_eq!(adjusted[0].position, [1.0, 2.0, 87.5]);
assert_eq!(adjusted[1].position, roots[1].position);
let transform = LegacyIron3dEulerTransform {
translation: adjusted[0].position,
orientation_radians: adjusted[0].orientation_raw,
};
assert_eq!(
transform.try_transform_scaled_point([0.0, 0.0, 0.0], adjusted[0].scale),
Some([1.0, 2.0, 87.5])
);
assert_eq!(
transform.try_transform_scaled_point([0.0, 0.0, 4.0], adjusted[0].scale),
Some([1.0, 2.0, 91.5]),
"the attached component inherits the root shift once"
);
}
#[test]
fn basement_projection_shadows_and_queries_share_generated_base_only_faces() {
let mut land = fparkan_terrain_format::LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: fparkan_terrain_format::TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
positions: vec![[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
normals: vec![[127, 0, 0, 0], [0, 127, 0, 0], [0, 0, 127, 0]],
uv0: vec![[256, 128], [1024, 256], [512, 1536]],
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: vec![fparkan_terrain_format::TerrainFace28 {
flags: FullSurfaceMask(0x400),
material_tag: 0xff00,
aux_tag: 0,
vertices: [0, 1, 2],
neighbors: [None, None, None],
tail_raw: [0; 8],
raw: [0; 28],
}],
};
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let source_mesh = project_land_msh_to_static_mesh_in_world_space(&land)
.expect("project one LOD0 terrain face");
let source_world = TerrainWorld::from_land_msh(&land).expect("source terrain");
let (empty_mesh, empty_shadows, empty_query) =
prepare_static_terrain_geometry(&land, source_mesh.clone(), &source_world, None)
.expect("keep unmodified terrain when there are no foundations");
assert_eq!(empty_mesh, source_mesh);
assert_eq!(empty_query.surface_count(), source_world.surface_count());
assert_eq!(empty_shadows.len(), 1);
assert!(empty_mesh
.vertices
.iter()
.any(|vertex| vertex.normal != [0.0; 3]));
assert!(empty_mesh
.vertices
.iter()
.any(|vertex| vertex.uv != [0.0; 2]));
let band_positions = [
[-18.25, -18.25, 5.0],
[-2.25, -18.25, 5.0],
[-18.25, -2.25, 5.0],
];
let mut foundation = BasementFoundation {
fragments: vec![
BasementFoundationFragment {
source_face: Some(0),
surface_mask: FullSurfaceMask(0x400),
positions: [[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
},
BasementFoundationFragment {
source_face: None,
surface_mask: FullSurfaceMask(0x8300),
positions: band_positions,
},
],
placements: Vec::new(),
};
let (render_mesh, shadow_receivers) =
project_basement_foundation(&land, &source_mesh, &mut foundation)
.expect("project canonical basement faces");
let expected = foundation.fragments.clone();
assert_eq!(render_mesh.draw_ranges.len(), 2);
assert!(render_mesh
.draw_ranges
.iter()
.all(|range| range.material_index == 0xff00));
assert!(!render_mesh.draw_ranges[0].terrain_base_only);
assert!(render_mesh.draw_ranges[1].terrain_base_only);
assert_eq!(render_mesh.draw_ranges[1].first_index, 3);
assert_eq!(shadow_receivers.len(), expected.len());
for (triangle_index, fragment) in expected.iter().enumerate() {
let indices: [u32; 3] = render_mesh.indices[triangle_index * 3..triangle_index * 3 + 3]
.try_into()
.expect("projected foundation triangle");
let positions = indices.map(|index| render_mesh.vertices[index as usize].position);
assert_eq!(positions, fragment.positions);
assert_eq!(
shadow_receivers[triangle_index].positions,
fragment.positions
);
assert_eq!(
shadow_receivers[triangle_index].mask,
fragment.surface_mask.0
);
}
assert_eq!(
basement_band_uv_vertices(band_positions)
.expect("native per-face UVs")
.map(|vertex| vertex.uv0),
[[815, 815], [1896, 815], [815, 1896]],
"negative coordinates use one native floor origin per triangle"
);
assert!(render_mesh.vertices[3..6]
.iter()
.flat_map(|vertex| vertex.uv)
.all(|value| (value * 1024.0).fract() == 0.0));
let query_world = source_world
.with_basement_foundation(foundation)
.expect("install canonical geometry");
assert_eq!(query_world.surface_count(), expected.len());
assert_eq!(
query_world
.basement_foundation()
.expect("installed foundation")
.fragments,
expected
);
assert_eq!(query_world.height_at([-14.0, -14.0]).unwrap(), Some(5.0));
}
#[test]
fn basement_band_normals_sum_packed_faces_for_native_welded_vertices() {
let mut foundation = BasementFoundation {
fragments: vec![
BasementFoundationFragment {
source_face: None,
surface_mask: FullSurfaceMask(0x8300),
positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
},
BasementFoundationFragment {
source_face: None,
surface_mask: FullSurfaceMask(0x8300),
positions: [[0.005, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 1.0]],
},
],
placements: Vec::new(),
};
let (normals, uv0_words) = canonicalize_basement_band_vertices(&mut foundation)
.expect("native packed normal pass");
let first = normals[0].expect("first synthetic face");
let second = normals[1].expect("second synthetic face");
assert_eq!(first[0], second[0], "sub-centimeter XY vertices are welded");
assert_eq!(foundation.fragments[1].positions[0], [0.0, 0.0, 0.0]);
assert_eq!(
first[2], second[1],
"shared vertices use both packed face normals"
);
assert_eq!(uv0_words[1].expect("second face UV words")[0], [0, 0]);
assert_eq!(first[0][1], 0.0);
assert_ne!(first[0], [0.0, 0.0, 127.0 / 128.0]);
}
#[test]
fn basement_face_normal_distinguishes_degenerate_from_non_finite() {
assert_eq!(
packed_basement_face_normal([[0.0; 3], [1.0; 3], [2.0; 3]])
.expect("zero normal is a native zero contribution"),
None
);
assert!(packed_basement_face_normal([
[f32::NAN, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0]
])
.is_err());
}
#[test]
fn c00_m02_world_terrain_edge_rounding_keeps_overlay_alpha_in_range() {
// DATA/MAPS/Tut_2/Land.msh face 2525: this split point lands about
// 1.35e-6 outside one source edge after the contour kernel's f32
// world-coordinate output is converted back to f64 barycentrics.
let source = [
VulkanStaticVertex {
position: [815.20465, 281.67642, 320.0],
color: [1.0; 3],
normal: [0.0, 0.0, 1.0],
uv: [0.0; 2],
detail_uv: [0.0; 2],
overlay_alpha: 1.0,
},
VulkanStaticVertex {
position: [775.4386, 308.18713, 360.00003],
color: [1.0; 3],
normal: [0.0, 0.0, 1.0],
uv: [0.0; 2],
detail_uv: [0.0; 2],
overlay_alpha: 1.0,
},
VulkanStaticVertex {
position: [775.4386, 288.3041, 361.6667],
color: [1.0; 3],
normal: [0.0, 0.0, 1.0],
uv: [0.0; 2],
detail_uv: [0.0; 2],
overlay_alpha: 1.0,
},
];
let split_position = [800.6648, 291.3697, 334.62543];
let point = [f64::from(split_position[0]), f64::from(split_position[1])];
let a = [
f64::from(source[0].position[0]),
f64::from(source[0].position[1]),
];
let b = [
f64::from(source[1].position[0]),
f64::from(source[1].position[1]),
];
let c = [
f64::from(source[2].position[0]),
f64::from(source[2].position[1]),
];
let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
let second_weight =
((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator;
let third_weight =
((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator;
let raw_overlay_alpha = [
1.0 - second_weight - third_weight,
second_weight,
third_weight,
]
.map(|weight| weight as f32)
.into_iter()
.sum::<f32>();
assert!(raw_overlay_alpha > 1.0 && raw_overlay_alpha <= 1.0 + 1.0e-5);
let vertex = interpolate_static_terrain_vertex(split_position, source)
.expect("bounded alpha rounding at a contour split");
assert_eq!(vertex.overlay_alpha, 1.0);
assert!((0.0..=1.0).contains(&vertex.overlay_alpha));
}
#[test]
fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() {
let camera = FreeFlightCamera {
position: [10.0, 20.0, 30.0],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
ground_clearance: 0.0,
}
.preview_frame(16.0 / 9.0);
let terrain = TerrainWorld::default();
assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0]));
assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0]));
let mut land = fparkan_terrain_format::LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: fparkan_terrain_format::TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
// Nonzero source Z is shared by the camera, terrain and shadows.
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
normals: Vec::new(),
uv0: Vec::new(),
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: vec![fparkan_terrain_format::TerrainFace28 {
flags: FullSurfaceMask(1),
material_tag: 0,
aux_tag: 0,
vertices: [0, 1, 2],
neighbors: [None, None, None],
tail_raw: [0; 8],
raw: [0; 28],
}],
};
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers");
assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]);
let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain");
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0));
let floor_camera =
FreeFlightCamera::from_mesh_and_terrain(&VulkanStaticMesh::smoke_triangle(), &terrain)
.expect("camera above nonzero terrain");
assert_eq!(floor_camera.position[2], 34.0);
assert_eq!(floor_camera.ground_clearance, 2.0);
let blocked_camera = FreeFlightCamera {
position: [0.25, 0.25, 34.0],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
ground_clearance: 0.0,
}
.preview_frame(16.0 / 9.0);
assert!(!native_sun_unoccluded(
&terrain,
&blocked_camera,
[0.0, 0.0, -1.0]
));
// The same hit is beyond a short native sun segment, so it must not
// occlude the light even though the terrain contains a surface.
let short_segment_camera = PreviewCameraFrame {
far_plane: 1.0,
..blocked_camera
};
assert!(native_sun_unoccluded(
&terrain,
&short_segment_camera,
[0.0, 0.0, -1.0]
));
let world_mesh = VulkanStaticMesh::smoke_triangle();
assert!(!native_sun_unoccluded_with_world(
&TerrainWorld::default(),
&world_mesh,
&[0],
&blocked_camera,
[0.0, 0.0, -1.0]
));
assert!(native_sun_unoccluded_with_world(
&TerrainWorld::default(),
&world_mesh,
&[],
&blocked_camera,
[0.0, 0.0, -1.0]
));
let mut excluded_land = fparkan_terrain_format::LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: fparkan_terrain_format::TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
normals: Vec::new(),
uv0: Vec::new(),
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: vec![fparkan_terrain_format::TerrainFace28 {
flags: FullSurfaceMask(0x20),
material_tag: 0,
aux_tag: 0,
vertices: [0, 1, 2],
neighbors: [None, None, None],
tail_raw: [0; 8],
raw: [0; 28],
}],
};
set_test_terrain_lod_slots(&mut excluded_land, &[[0, u16::MAX]], &[[0, 1]]);
let excluded_terrain =
TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain");
assert!(native_sun_unoccluded_with_world(
&excluded_terrain,
&world_mesh,
&[],
&blocked_camera,
[0.0, 0.0, -1.0]
));
let mut excluded_world_mesh = world_mesh.clone();
excluded_world_mesh.draw_ranges[0].batch_flags = 0x0008;
assert!(native_sun_unoccluded_with_world(
&TerrainWorld::default(),
&excluded_world_mesh,
&[0],
&blocked_camera,
[0.0, 0.0, -1.0]
));
}
#[test]
fn free_flight_camera_keeps_clearance_after_descending_and_moving_uphill() {
let mut land = fparkan_terrain_format::LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: fparkan_terrain_format::TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 34.0], [0.0, 1.0, 36.0]],
normals: Vec::new(),
uv0: Vec::new(),
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: vec![fparkan_terrain_format::TerrainFace28 {
flags: FullSurfaceMask(1),
material_tag: 0,
aux_tag: 0,
vertices: [0, 1, 2],
neighbors: [None, None, None],
tail_raw: [0; 8],
raw: [0; 28],
}],
};
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
let terrain = TerrainWorld::from_land_msh(&land).expect("sloped terrain");
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(33.5));
assert_eq!(terrain.height_at([0.75, 0.25]).unwrap(), Some(34.5));
let mut camera = FreeFlightCamera {
position: [0.25, 0.25, 37.0],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 4.0,
ground_clearance: 2.0,
};
let mut keys = HashSet::from([KeyCode::KeyQ]);
camera.advance(&keys, 1.0);
camera
.keep_above_terrain(&terrain)
.expect("floor query after descent");
assert_eq!(camera.position, [0.25, 0.25, 35.5]);
keys.clear();
keys.insert(KeyCode::KeyW);
camera.move_speed = 1.0;
camera.advance(&keys, 0.5);
camera
.keep_above_terrain(&terrain)
.expect("floor query after uphill movement");
assert_eq!(camera.position, [0.75, 0.25, 36.5]);
let mut outside_camera = FreeFlightCamera {
position: [2.0, -2.0, -17.0],
ground_clearance: 2.0,
..camera
};
outside_camera
.keep_above_terrain(&terrain)
.expect("clamp to the map before sampling the floor");
assert_eq!(outside_camera.position, [1.0, 0.0, 36.0]);
outside_camera.position = [-2.0, 2.0, 100.0];
outside_camera
.keep_above_terrain(&terrain)
.expect("clamp the other map edges without limiting altitude");
assert_eq!(outside_camera.position, [0.0, 1.0, 100.0]);
outside_camera.move_speed = 40.0;
outside_camera.advance(&HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]), 0.1);
outside_camera
.keep_above_terrain(&terrain)
.expect("accelerated movement still stops at the map edge");
assert_eq!(outside_camera.position, [1.0, 1.0, 100.0]);
let mut unsupported_camera = FreeFlightCamera {
position: [0.25, 0.25, 35.5],
ground_clearance: 2.0,
..camera
};
assert!(unsupported_camera
.keep_above_terrain(&TerrainWorld::default())
.expect_err("terrain query errors must propagate")
.contains("terrain height query"));
}
#[test]
#[ignore = "requires the original GOG installation"]
fn free_flight_camera_stops_at_original_map_edges_under_accelerated_movement() {
let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
.expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
let mut engine =
create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine");
load_mission_static_preview(
&mut engine,
MissionRequest {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
)
.expect("original mission");
let terrain = loaded_terrain(&engine).expect("original terrain");
let [min, max] = terrain.surface_bounds().expect("original map bounds");
let keys = HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]);
for (axis, yaw, edge) in [
(0, 0.0, max[0]),
(0, std::f32::consts::PI, min[0]),
(1, std::f32::consts::FRAC_PI_2, max[1]),
(1, -std::f32::consts::FRAC_PI_2, min[1]),
] {
let mut camera = FreeFlightCamera::from_bounds(min, max).expect("camera");
camera.position = [
(min[0] + max[0]) * 0.5,
(min[1] + max[1]) * 0.5,
max[2] + 10.0,
];
camera.yaw = yaw;
camera.pitch = 0.0;
for _ in 0..512 {
camera.advance(&keys, 0.1);
camera.keep_above_terrain(terrain).expect("map constraint");
for axis in 0..2 {
assert!((min[axis]..=max[axis]).contains(&camera.position[axis]));
}
}
assert_eq!(camera.position[axis], edge);
}
}
#[test]
fn world_sort_key_uses_the_first_indexed_vertex() {
let mesh = VulkanStaticMesh::smoke_triangle();
let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0])
.expect("first indexed vertex distance");
assert!((distance - 3.0).abs() < 1.0e-6);
}
#[test]
fn free_flight_camera_advances_wasd_and_vertical_controls() {
let mut camera = FreeFlightCamera {
position: [0.0, 0.0, 0.0],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 2.0,
ground_clearance: 0.0,
};
let mut keys = HashSet::new();
keys.insert(KeyCode::KeyW);
keys.insert(KeyCode::KeyD);
camera.advance(&keys, 1.0);
let diagonal = 2.0 / 2.0_f32.sqrt();
assert!((camera.position[0] - diagonal).abs() < 1.0e-5);
assert!((camera.position[1] + diagonal).abs() < 1.0e-5);
assert_eq!(camera.position[2], 0.0);
keys.clear();
keys.insert(KeyCode::KeyE);
keys.insert(KeyCode::ShiftLeft);
camera.advance(&keys, 0.5);
assert!((camera.position[2] - 4.0).abs() < 1.0e-5);
}
#[test]
fn free_flight_mouse_right_turns_toward_previous_right_and_vertical_is_world_z() {
let mut camera = FreeFlightCamera {
position: [0.0, 0.0, 0.0],
yaw: 0.0,
pitch: 0.6,
horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 2.0,
ground_clearance: 0.0,
};
let old_right = camera.right();
let old_forward = camera.forward();
camera.look_delta(100.0, 0.0);
assert!(dot3(camera.forward(), old_right) > 0.0);
assert!(dot3(camera.forward(), old_forward) < 1.0);
let mut keys = HashSet::new();
keys.insert(KeyCode::KeyE);
camera.advance(&keys, 0.5);
assert!((camera.position[0]).abs() < 1.0e-5);
assert!((camera.position[1]).abs() < 1.0e-5);
assert!((camera.position[2] - 1.0).abs() < 1.0e-5);
}
#[test]
fn parses_validation_flag() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--validation",
]))
.expect("valid validation flag");
assert!(parsed.validation);
}
#[test]
fn parses_nonzero_static_preview_root_count() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--preview-roots",
"2",
]))
.expect("valid static preview arguments");
assert_eq!(
parsed.preview_roots,
NonZeroUsize::new(2).expect("non-zero literal")
);
}
#[test]
fn parses_readback_output() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--readback-out",
"target/frame.raw",
]))
.expect("valid readback output arguments");
assert_eq!(parsed.readback_out, Some(PathBuf::from("target/frame.raw")));
}
#[test]
fn controlled_readback_sequence_requires_and_parses_replay_clock() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--frames",
"11",
"--control-clock-ms",
"0",
"--readback-sequence-out",
"target/frames",
]))
.expect("valid fixed-clock readback sequence");
assert_eq!(
parsed.readback_sequence_out,
Some(PathBuf::from("target/frames"))
);
assert_eq!(parsed.control_clock_ms, Some(0));
let error = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--frames",
"11",
"--readback-sequence-out",
"target/frames",
]))
.expect_err("sequence captures need deterministic clock input");
assert!(error.contains("requires --control-clock-ms"));
}
#[test]
fn readback_sequence_writes_clock_named_frame_without_overwrite() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock after epoch")
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"fparkan-readback-sequence-{}-{nonce}",
std::process::id()
));
let artifact = VulkanReadbackArtifact {
format: 37,
width: 2,
height: 1,
bytes: vec![1, 2, 3, 4, 5, 6, 7, 8],
};
let path = write_readback_sequence_frame(&directory, 3, 50, &artifact)
.expect("write frame artifact");
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("frame-000003-clock-0000000050ms-2x1.rgba")
);
assert_eq!(
std::fs::read(&path).expect("read frame artifact"),
artifact.bytes
);
assert!(write_readback_sequence_frame(&directory, 3, 50, &artifact).is_err());
std::fs::remove_dir_all(directory).expect("remove frame artifact directory");
}
#[test]
fn parses_static_animation_frame() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--static-animation-frame",
"12",
]))
.expect("valid static animation frame");
assert_eq!(parsed.static_animation_frame, Some(12));
}
#[test]
fn static_preview_control_defaults_apply_native_flags_and_last_row_wins() {
let binding =
|node_index, frame_a, frame_b, initial_blend, flags| PreparedControlNodeBinding {
node_index,
frame_a,
frame_b,
initial_blend,
flags,
};
let times = static_preview_node_times(
6,
&[
binding(1, 1.0, 3.0, 0.0, 0),
binding(2, 1.0, 3.0, 1.25, 1),
binding(3, 1.0, 3.0, -0.5, 1),
binding(4, 1.0, 3.0, -0.5, 0),
binding(1, 1.0, 3.0, 0.25, 2),
binding(usize::MAX, f32::NAN, f32::INFINITY, f32::NAN, 4),
],
)
.expect("valid static CTLD defaults");
let values = times
.iter()
.map(|time| time.map(|time| time.0))
.collect::<Vec<_>>();
assert_eq!(
values,
vec![
Some(0.0),
Some(2.5),
Some(1.5),
Some(2.0),
Some(1.0),
Some(0.0)
]
);
}
#[test]
fn static_preview_rejects_negative_derived_control_sample_time() {
let error = static_preview_node_times(
1,
&[PreparedControlNodeBinding {
node_index: 0,
frame_a: 0.0,
frame_b: 2.0,
initial_blend: -2.0,
flags: 1,
}],
)
.expect_err("one wrap still leaves an unsupported negative sample time");
assert!(error.contains("negative or non-finite sample time"));
}
#[test]
fn parses_static_material_phase() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--static-material-phase",
"2",
]))
.expect("valid static material phase");
assert_eq!(parsed.static_material_phase, Some(2));
}
#[test]
fn rejects_zero_static_preview_root_count() {
let error = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--preview-roots",
"0",
]));
assert_eq!(
error,
Err("--preview-roots must be a non-zero integer".to_string())
);
}
#[test]
fn parses_legacy_camera_capture_path() {
let valid = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--legacy-camera-capture",
"target/camera.json",
]))
.expect("valid legacy camera arguments");
assert_eq!(
valid.legacy_camera_capture,
Some(PathBuf::from("target/camera.json"))
);
}
#[test]
fn legacy_camera_capture_parses_exact_d3d7_inputs() {
let words = [
0.0_f32.to_bits(),
(-1.0_f32).to_bits(),
0.0_f32.to_bits(),
10.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
20.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
30.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 json = format!(
"{{\"schema\":\"fparkan-legacy-camera-v1\",\"render_input_usable\":true,\"atmosphere_seconds\":56.227,\"selector0_words\":{:?},\"viewport\":[23,17,1047,785],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.3}}",
words
);
let capture = parse_legacy_camera_capture(json.as_bytes()).expect("valid legacy camera");
let camera = capture.preview_camera_frame().expect("valid preview frame");
assert_eq!(capture.render_input_usable, Some(true));
assert_eq!(capture.atmosphere_seconds, Some(56.227));
assert!(camera.clip_from_world.iter().all(|value| value.is_finite()));
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos());
assert_eq!(capture.viewport, [23, 17, 1047, 785]);
assert_eq!(
capture.captured_viewport().unwrap().rect,
[23, 17, 1047, 785]
);
assert_eq!(capture.captured_viewport().unwrap().extent, [1024, 768]);
assert!((horizontal_fov(&camera, 4.0 / 3.0) - 1.3).abs() < 1.0e-5);
assert_eq!(
camera.vulkan_camera().clip_from_world,
camera.clip_from_world
);
let mut utf16le = vec![0xff, 0xfe];
utf16le.extend(json.encode_utf16().flat_map(u16::to_le_bytes));
assert_eq!(
parse_legacy_camera_capture(&utf16le)
.expect("PowerShell UTF-16LE capture must be accepted")
.preview_camera_frame()
.expect("UTF-16 preview frame")
.clip_from_world,
camera.clip_from_world
);
assert_eq!(
parse_legacy_camera_capture(br#"{"schema":"unknown","selector0_words":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"viewport":[0,0,1,1],"near_plane":0.1,"far_plane":1.0,"field_of_view_radians":1.0}"#),
Err("unsupported legacy camera capture schema".to_string())
);
}
#[test]
fn parented_camera_preview_uses_effective_world_matrix_and_legacy_uses_local() {
let local_words = [
1065353216, 0, 0, 1065353216, 0, 1065353216, 0, 1073741824, 0, 0, 1065353216,
1077936128, 0, 0, 0, 1065353216,
];
let world_words = [
1065353216, 0, 0, 1092616192, 0, 1065353216, 0, 1101004800, 0, 0, 1065353216,
1106247680, 0, 0, 0, 1065353216,
];
let make_json = |extra: &str| {
format!(
"{{\"schema\":\"fparkan-legacy-camera-v1\",\"render_input_usable\":true,\"selector0_words\":{:?},{}\"viewport\":[0,0,1280,1024],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.04}}",
local_words, extra
)
};
let legacy = parse_legacy_camera_capture(make_json("").as_bytes())
.expect("legacy camera without parent metadata");
assert_eq!(legacy.preview_matrix_words().unwrap(), local_words);
assert_eq!(
legacy.preview_camera_frame().unwrap().position,
[1.0, 2.0, 3.0]
);
let parented = parse_legacy_camera_capture(
make_json(&format!(
"\"parented_camera\":true,\"effective_world_matrix_space\":\"world\",\"effective_world_matrix_words\":{:?},",
world_words
))
.as_bytes(),
)
.expect("parented camera with effective world matrix");
assert_eq!(parented.preview_matrix_words().unwrap(), world_words);
let preview = parented.preview_camera_frame().unwrap();
assert_eq!(preview.position, [10.0, 20.0, 30.0]);
assert_ne!(
preview.position,
legacy.preview_camera_frame().unwrap().position
);
let missing_world = make_json("\"parented_camera\":true,");
assert!(parse_legacy_camera_capture(missing_world.as_bytes())
.expect_err("parented local-only input must be rejected")
.contains("parented legacy camera requires an effective world-space matrix"));
let missing_marker = make_json(&format!(
"\"parented_camera\":true,\"effective_world_matrix_words\":{:?},",
world_words
));
assert!(parse_legacy_camera_capture(missing_marker.as_bytes())
.expect_err("world matrix requires space marker")
.contains("effective_world_matrix_space and effective_world_matrix_words"));
}
#[test]
fn parented_camera_preview_uses_effective_world_matrix_fixture() {
let fixture = include_bytes!("../testdata/legacy-camera-parented.json");
let capture = parse_legacy_camera_capture(fixture).expect("valid parented camera fixture");
let parse_value = |value: serde_json::Value| {
let bytes = serde_json::to_vec(&value).expect("serialize camera fixture");
parse_legacy_camera_capture(&bytes)
};
assert_eq!(capture.parented_camera, Some(true));
assert_eq!(
capture.preview_camera_frame().unwrap().position,
[10.0, 20.0, 30.0]
);
assert_eq!(
capture.preview_matrix_words().unwrap(),
capture.effective_world_matrix_words.unwrap()
);
let mut missing_world: serde_json::Value =
serde_json::from_slice(fixture).expect("valid fixture JSON");
missing_world
.as_object_mut()
.unwrap()
.remove("effective_world_matrix_words");
let missing_world = serde_json::to_vec(&missing_world).unwrap();
assert!(parse_legacy_camera_capture(&missing_world)
.expect_err("incomplete world matrix pair")
.contains("effective_world_matrix_space and effective_world_matrix_words"));
let mut parented_without_world: serde_json::Value =
serde_json::from_slice(fixture).expect("valid fixture JSON");
let parented_without_world = parented_without_world.as_object_mut().unwrap();
parented_without_world.remove("effective_world_matrix_space");
parented_without_world.remove("effective_world_matrix_words");
assert!(
parse_value(serde_json::Value::Object(parented_without_world.clone()))
.expect_err("parented camera without its world matrix")
.contains("parented legacy camera requires an effective world-space matrix")
);
let mut invalid_marker: serde_json::Value =
serde_json::from_slice(fixture).expect("valid fixture JSON");
invalid_marker["effective_world_matrix_space"] =
serde_json::Value::String("local".to_string());
assert!(parse_value(invalid_marker)
.expect_err("unknown matrix space")
.contains("effective_world_matrix_space must be 'world'"));
let mut legacy: serde_json::Value =
serde_json::from_slice(fixture).expect("valid fixture JSON");
let legacy = legacy.as_object_mut().unwrap();
legacy.remove("parented_camera");
legacy.remove("effective_world_matrix_space");
legacy.remove("effective_world_matrix_words");
let legacy = parse_value(serde_json::Value::Object(legacy.clone()))
.expect("older local-only camera capture");
assert_eq!(
legacy.preview_camera_frame().unwrap().position,
[1.0, 2.0, 3.0]
);
}
#[test]
fn captured_sky_uv_phase_requires_verified_clock() {
let mut value: serde_json::Value =
serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json"))
.unwrap();
value["sky_uv_clock"] =
serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32});
let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(
capture.sky_uv_clock.unwrap().elapsed_milliseconds,
4051271258
);
value["sky_uv_clock"]["verified"] = serde_json::json!(false);
assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn captured_atmosphere_is_default_and_cli_time_takes_precedence() {
let capture = LegacyCameraCapture {
schema: "fparkan-legacy-camera-v1".to_string(),
render_input_usable: None,
selector0_words: [0; 16],
parented_camera: None,
effective_world_matrix_space: None,
effective_world_matrix_words: None,
viewport: [0, 0, 1, 1],
near_plane: 0.1,
far_plane: 1.0,
field_of_view_radians: 1.0,
atmosphere_seconds: Some(56.227),
geometry_projection: None,
sky_uv_clock: None,
};
assert_eq!(
selected_atmosphere_seconds(None, Some(&capture)),
Some(56.227)
);
assert_eq!(
selected_atmosphere_seconds(Some(100.0), Some(&capture)),
Some(100.0)
);
assert_eq!(selected_atmosphere_seconds(None, None), None);
}
#[test]
fn captured_environment_holds_exact_phase_while_free_time_advances() {
let (fixed_relative, fixed_absolute) =
environment_sample_time(0.227, 56.0, 0.25, Some(56.227));
assert_eq!(fixed_relative, 0.227);
assert_eq!(fixed_absolute, 56.227);
let (free_relative, free_absolute) = environment_sample_time(0.227, 56.0, 0.25, None);
assert_eq!(free_relative, 0.477);
assert_eq!(free_absolute, 56.477);
}
#[test]
fn legacy_camera_capture_rejects_invalid_phase_and_unusable_input() {
let base = r#"{"schema":"fparkan-legacy-camera-v1","selector0_words":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"viewport":[0,0,1,1],"near_plane":0.1,"far_plane":1.0,"field_of_view_radians":1.0}"#;
let invalid_phase = base.replace(
"\"near_plane\"",
"\"atmosphere_seconds\":-1.0,\"near_plane\"",
);
assert!(parse_legacy_camera_capture(invalid_phase.as_bytes())
.expect_err("negative captured phase")
.contains("atmosphere_seconds must be finite and non-negative"));
let unusable = base.replace(
"\"near_plane\"",
"\"render_input_usable\":false,\"near_plane\"",
);
assert!(parse_legacy_camera_capture(unusable.as_bytes())
.expect_err("explicitly unusable camera capture")
.contains("render_input_usable=false"));
}
#[test]
fn captured_camera_frame_keeps_native_roll_basis() {
let roll = 0.37_f32;
let (sin, cos) = roll.sin_cos();
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(),
cos.to_bits(),
(-sin).to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
sin.to_bits(),
cos.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, 800, 600],
near_plane: 0.1,
far_plane: 500.0,
field_of_view_radians: 1.0,
};
let view = transform
.try_direct3d7_view_row_major()
.expect("valid native camera basis");
let frame = PreviewCameraFrame::from_legacy_d3d7(transform, projection)
.expect("valid captured frame");
assert_eq!(frame.forward, [view[2], view[6], view[10]]);
assert_eq!(frame.right, [view[0], view[4], view[8]]);
assert_eq!(frame.up, [view[1], view[5], view[9]]);
assert!(frame.right[2].abs() > 0.3, "roll must tilt the right axis");
assert!(frame.up[1].abs() > 0.3, "roll must tilt the up axis");
assert_eq!(
frame.clip_from_world,
VulkanStaticCamera::from_legacy_d3d7(transform, projection)
.expect("legacy clip matrix")
.clip_from_world
);
}
#[test]
fn captured_camera_refuses_a_different_drawable_extent() {
let captured = CapturedViewport {
rect: [23, 17, 1047, 785],
extent: [1024, 768],
};
assert!(validate_captured_drawable_extent(captured, [1024, 768]).is_ok());
let error = validate_captured_drawable_extent(captured, [1280, 720])
.expect_err("a mismatched surface must not stretch the captured view");
assert!(error.contains("[23, 17, 1047, 785]"));
assert!(error.contains("1024x768"));
assert!(error.contains("1280x720"));
}
#[test]
fn static_preview_component_merge_offsets_indices_and_remaps_local_selectors(
) -> Result<(), String> {
let mut merged = VulkanStaticMesh {
vertices: Vec::new(),
indices: Vec::new(),
draw_ranges: Vec::new(),
};
append_static_preview_component(
&mut merged,
VulkanStaticMesh::smoke_triangle(),
&[(0, 4)],
None,
)?;
append_static_preview_component(
&mut merged,
VulkanStaticMesh::smoke_triangle(),
&[(0, 9)],
None,
)?;
assert_eq!(merged.vertices.len(), 6);
assert_eq!(merged.indices, vec![0, 1, 2, 3, 4, 5]);
assert_eq!(merged.draw_ranges.len(), 2);
assert_eq!(merged.draw_ranges[0].first_index, 0);
assert_eq!(merged.draw_ranges[0].material_index, 4);
assert_eq!(merged.draw_ranges[1].first_index, 3);
assert_eq!(merged.draw_ranges[1].material_index, 9);
Ok(())
}
#[test]
fn static_preview_component_merge_keeps_indices_above_u16() -> Result<(), String> {
let vertex = fparkan_render_vulkan::VulkanStaticVertex {
position: [0.0, 0.0, 0.0],
color: [1.0, 1.0, 1.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
detail_uv: [0.0, 0.0],
overlay_alpha: 0.0,
};
let mut merged = VulkanStaticMesh {
vertices: vec![vertex; usize::from(u16::MAX) + 1],
indices: Vec::new(),
draw_ranges: Vec::new(),
};
append_static_preview_component(
&mut merged,
VulkanStaticMesh::smoke_triangle(),
&[(0, 0)],
None,
)?;
assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]);
Ok(())
}
#[test]
fn precipitation_upload_reconstructs_native_ndc_depths_and_uvs() {
let camera = FreeFlightCamera {
position: [10.0, 20.0, 30.0],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: std::f32::consts::FRAC_PI_2,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
ground_clearance: 0.0,
}
.preview_frame(1.0);
let screen = ScreenBillboard {
world_head: [0.0; 3],
world_tail: [0.0; 3],
head: [0.0, 0.0],
tail: [0.0, 0.0],
corners: [[-0.25, -0.5], [0.25, -0.5], [-0.25, 0.5], [0.25, 0.5]],
uv: [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]],
half_size: 12.0,
head_depth: 8.0,
tail_depth: 4.0,
};
let vertices = precipitation_quad_vertices(
PrecipitationKind::Rain,
&screen,
[1.0, 0.5, 0.25, 0.75],
&camera,
[100.0, 100.0],
);
assert_eq!(vertices[0].uv, screen.uv[0]);
assert_eq!(vertices[3].uv, screen.uv[3]);
assert!(
(dot3(
sub3(vertices[0].position, camera.position),
camera.forward()
) - 4.0)
.abs()
< 1.0e-5
);
assert!(
(dot3(
sub3(vertices[3].position, camera.position),
camera.forward()
) - 8.0)
.abs()
< 1.0e-5
);
assert_eq!(vertices[0].overlay_alpha, 0.75);
}
#[test]
fn sprite_upload_remaps_native_left_top_uv_order_to_quad_positions() {
let camera = FreeFlightCamera {
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
horizontal_fov: 1.0,
near_plane: 0.1,
far_plane: 100.0,
move_speed: 1.0,
ground_clearance: 0.0,
}
.preview_frame(1.0);
let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
let vertices = sprite_quad_vertices_pixels(
&camera,
camera.forward(),
10.0,
1.0,
1.0,
[100.0, 100.0],
[1.0; 3],
1.0,
uv,
);
assert_eq!(
vertices.map(|vertex| vertex.uv),
[uv[1], uv[2], uv[0], uv[3]]
);
}
#[test]
fn control_type4_basis_branches_preserve_anisotropic_owner_scale() {
let owner_scale = [2.0, 3.0, 4.0];
let equal = [
control_point([0.0; 3], [1.0, 1.0, 0.0]),
control_point([0.0; 3], [1.0, 1.0, 0.0]),
control_point([0.0; 3], [1.0, 1.0, 0.0]),
];
let equal_matrix =
control_type4_local_matrix([&equal[0], &equal[1], &equal[2]], owner_scale).unwrap();
assert_matrix_columns(
equal_matrix,
[
[2.0, 3.0, 0.0],
[-3.0, 2.0, 0.0],
[0.0, 0.0, 13.0_f32.sqrt()],
],
);
let unique = [
control_point([1.0; 3], [1.0, 2.0, 3.0]),
control_point([2.0; 3], [4.0, 5.0, 6.0]),
control_point([3.0; 3], [-1.0, 1.0, 2.0]),
];
let unique_matrix =
control_type4_local_matrix([&unique[0], &unique[1], &unique[2]], owner_scale).unwrap();
assert_matrix_columns(
unique_matrix,
[[2.0, 6.0, 12.0], [8.0, 15.0, 24.0], [-2.0, 3.0, 8.0]],
);
assert_eq!(
[
unique_matrix[0][3],
unique_matrix[1][3],
unique_matrix[2][3]
],
[4.0, 6.0, 8.0]
);
for (directions, expected) in [
(
[[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
[[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]],
),
(
[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
[[0.0, 0.0, 4.0], [0.0, -1.0, 0.0], [4.0, 0.0, 0.0]],
),
(
[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]],
[[0.0, 3.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 3.0]],
),
(
[[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
[[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]],
),
] {
let repeated = directions.map(|direction| control_point([0.0; 3], direction));
let matrix =
control_type4_local_matrix([&repeated[0], &repeated[1], &repeated[2]], owner_scale)
.unwrap();
assert_matrix_columns(matrix, expected);
}
}
#[test]
fn control_type4_world_matrix_rotates_basis_and_keeps_scale_off_translation() {
let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
let local =
control_type4_effect_local_matrix([&point, &point, &point], [2.0, 3.0, 4.0]).unwrap();
let matrix = control_type4_world_from_effect(
local,
[2.0, 3.0, 4.0],
LegacyIron3dEulerTransform {
translation: [10.0, 20.0, 30.0],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
},
None,
VulkanNodePose {
translation: [0.0; 3],
rotation: [0.0, 0.0, 0.0, 1.0],
},
)
.unwrap();
assert_matrix_columns(
matrix,
[[0.0, 4.0, 0.0], [-9.0, 0.0, 0.0], [0.0, 0.0, 16.0]],
);
for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]]
.into_iter()
.zip([10.0, 22.0, 30.0])
{
assert!((actual - expected).abs() < 1.0e-5);
}
}
#[test]
fn mounted_control_effect_uses_component_pose_before_owner_world_transform() {
let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
let mount_pose = VulkanNodePose {
translation: [1.0, 2.0, 3.0],
rotation: [
0.0,
0.0,
std::f32::consts::FRAC_1_SQRT_2,
std::f32::consts::FRAC_1_SQRT_2,
],
};
let owner_node_pose = VulkanNodePose {
translation: [0.0; 3],
rotation: [0.0, 0.0, 0.0, 1.0],
};
let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3]).unwrap();
let matrix = control_type4_world_from_effect(
local,
[2.0, 3.0, 4.0],
LegacyIron3dEulerTransform {
translation: [10.0, 20.0, 30.0],
orientation_radians: [0.0; 3],
},
Some(mount_pose),
owner_node_pose,
)
.unwrap();
for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]]
.into_iter()
.zip([12.0, 29.0, 42.0])
{
assert!((actual - expected).abs() < 1.0e-5);
}
}
#[test]
fn plant_node23_control_points_match_captured_native_world_positions() {
let root_transform = LegacyIron3dEulerTransform {
translation: [323.55048, 806.97266, 0.36829287],
orientation_radians: [0.0, 0.0, 1.6262363],
};
let owner_node_pose = VulkanNodePose {
translation: [0.0, -68.15999, 21.12],
rotation: [0.0, 0.0, 0.0, 1.0],
};
let points = [
(
[-12.964036, 12.991658, -21.119995],
[379.35242, 797.0855, 0.3682986],
),
(
[13.49774, 12.991658, -21.119995],
[377.8861, 823.5066, 0.3682986],
),
(
[9.507235, -3.006884, -21.119993],
[394.08118, 820.40875, 0.3683005],
),
(
[-9.080112, -3.2843084, -21.119991],
[395.38815, 801.86536, 0.3683024],
),
];
for (local_position, expected_world) in points {
let point = control_point(local_position, [1.0, 0.0, 0.0]);
let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3])
.expect("CPT descriptor local matrix");
let world = control_type4_world_from_effect(
local,
[1.0; 3],
root_transform,
None,
owner_node_pose,
)
.expect("attached Control owner matrix");
for axis in 0..3 {
assert!(
(world[axis][3] - expected_world[axis]).abs() < 0.1,
"axis {axis}: actual {} expected {}",
world[axis][3],
expected_world[axis]
);
}
}
}
#[test]
fn ambient_effect_world_refreshes_from_each_component_pose_buffer() {
let mut poses = vec![fparkan_animation::Pose::default(); 24];
poses[23].translation = [0.0, -68.15999, 21.12];
let mut slot = AmbientEffectGpuSlot {
kind: AmbientEffectKind::Op3Type2,
time_mode: 1,
header_flags: 0,
random_instance_key: ControlEffectInstanceKey {
object_index: 0,
component_index: 0,
table_index: 8,
row_index: 6,
},
random_seed: 1,
phase_jitter: 0.0,
random_shift: [0.0; 3],
owner_object_index: 0,
owner_component_index: 0,
owner_node_index: 23,
owner_component_is_mounted: false,
root_transform: LegacyIron3dEulerTransform {
translation: [323.55048, 806.97266, 0.36829287],
orientation_radians: [0.0, 0.0, 1.6262363],
},
owner_scale: [1.0; 3],
mount_pose: None,
local_from_effect: [
[1.0, 0.0, 0.0, -12.964036],
[0.0, 1.0, 0.0, 12.991658],
[0.0, 0.0, 1.0, -21.119995],
[0.0, 0.0, 0.0, 1.0],
],
control_source: String::new(),
control_table_index: 8,
control_row_index: 6,
control_point_indices: [104; 3],
control_point_owners: [23; 3],
command: endpoint_op3_command(),
command_index: 0,
duration_seconds: 1.0,
world_from_effect: [[0.0; 4]; 4],
range: EnvironmentGpuRange {
range_index: 0,
vertex_start: 0,
vertex_capacity: 4,
material_index: 0,
},
additional_particle_ranges: Vec::new(),
material_animation_index: 0,
additional_particle_material_animation_indices: Vec::new(),
smoke_emitter_state: None,
op7_emitter_state: None,
};
let mut component_pose_buffers = vec![vec![Some(NodePoseBuffer { poses })]];
let first = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers)
.expect("first sampled owner pose");
assert!((first[0][3] - 379.35242).abs() < 0.1 && (first[1][3] - 797.0855).abs() < 0.1);
let pose = &mut component_pose_buffers[0][0].as_mut().unwrap().poses[23];
pose.translation = [
pose.translation[0] + 1.0,
pose.translation[1] + 2.0,
pose.translation[2] + 3.0,
];
let second = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers)
.expect("next sampled owner pose");
slot.world_from_effect = second;
assert_ne!(first, slot.world_from_effect);
let expected_delta = rotate_unit_vector(
[1.0, 2.0, 3.0],
[
0.0,
0.0,
(1.6262363_f32 * 0.5).sin(),
(1.6262363_f32 * 0.5).cos(),
],
);
for (axis, expected) in expected_delta.into_iter().enumerate() {
let delta = slot.world_from_effect[axis][3] - first[axis][3];
assert!((delta - expected).abs() < 0.1, "axis {axis}: {delta}");
}
}
#[test]
fn mode1_control_effects_hold_active_with_full_opacity_after_duration() {
let phase = native_command_phase(
0,
ambient_effect_phase(1, 0, 1.25, 1.0).expect("mode 1 phase"),
0.0,
);
assert_eq!(phase, 1.0);
let identity = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let lamp =
evaluate_op3_type2_command(&endpoint_op3_command(), 0, &identity, 1.25, phase, 0)
.expect("lamp endpoint");
assert!(lamp.active);
assert_eq!(lamp.color[3], 1.0);
let op9 = endpoint_gener_ball_command(FxOpcode::Op9);
let generated = evaluate_f_gener_ball_mesh(
&op9,
0,
&identity,
phase,
1.25,
[0.0, 0.0, 500.0],
AMBIENT_EFFECT_NATIVE_CATEGORY,
)
.expect("generator sphere endpoint");
assert!(generated.active);
assert_eq!(generated.color[3], 1.0);
assert_eq!(generated.vertices.len(), 88);
assert_eq!(generated.indices.len(), 120);
assert_eq!(generated.native_category, 0);
let op4 = endpoint_gener_ball_command(FxOpcode::Op4);
let orb = evaluate_f_gener_ball(&op4, 0, &identity, phase, 1.25, [0.0, 0.0, 500.0])
.expect("opcode 4 billboard endpoint");
assert!(orb.active);
assert_eq!(orb.color[3], 1.0);
}
#[test]
fn op9_mesh_bridge_allocates_one_range_per_quad_and_cap() {
let command = endpoint_gener_ball_command(FxOpcode::Op9);
let identity = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let frame = evaluate_f_gener_ball_mesh(
&command,
0,
&identity,
0.5,
0.5,
[0.0, 0.0, 500.0],
AMBIENT_EFFECT_NATIVE_CATEGORY,
)
.expect("category-zero Op9 mesh");
let primitives = ambient_effect_mesh_primitive_layout(&frame)
.expect("native Op9 quad/cap primitive layout");
assert_eq!(primitives.len(), 24);
assert_eq!(
primitives
.iter()
.filter(|item| item.vertex_count == 4)
.count(),
16
);
assert_eq!(
primitives
.iter()
.filter(|item| item.vertex_count == 3)
.count(),
8
);
let mut mesh = VulkanStaticMesh {
vertices: Vec::new(),
indices: Vec::new(),
draw_ranges: Vec::new(),
};
let ranges = primitives
.iter()
.copied()
.map(|primitive| {
let range = append_environment_range(
&mut mesh,
0,
ambient_effect_mesh_primitive_vertices(&frame, primitive),
ambient_effect_mesh_primitive_indices(&frame, primitive)
.expect("primitive-local indices"),
LegacyPipelineState::default(),
)
.expect("allocate one native primitive range");
write_ambient_effect_mesh_primitive(&mut mesh, &range, &frame, primitive)
.expect("write native primitive frame");
range
})
.collect::<Vec<_>>();
assert_eq!(ranges.len(), 24);
assert_eq!(mesh.draw_ranges.len(), 24);
assert_eq!(mesh.indices.len(), 120);
assert!(mesh.draw_ranges.iter().all(|range| {
range.alpha_test_reference == VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE
}));
assert_eq!(
mesh.draw_ranges
.iter()
.map(|range| range.index_count)
.sum::<u32>(),
120
);
for (range, primitive) in ranges.iter().zip(primitives) {
let vertices =
&mesh.vertices[range.vertex_start..range.vertex_start + range.vertex_capacity];
assert_eq!(range.vertex_capacity, primitive.vertex_count);
for (offset, vertex) in vertices.iter().enumerate() {
assert_eq!(
vertex.position,
frame.vertices[primitive.vertex_start + offset]
);
assert_eq!(vertex.uv, frame.uv[primitive.vertex_start + offset]);
assert_eq!(vertex.overlay_alpha, frame.color[3]);
}
let range_indices = &mesh.draw_ranges[range.range_index];
assert_eq!(range_indices.index_count as usize, primitive.index_count);
let first_index = range_indices.first_index as usize;
assert_eq!(
mesh.indices[first_index],
u32::try_from(range.vertex_start).unwrap()
);
let camera = [10.0, -5.0, 2.0];
let expected = sub3(frame.vertices[primitive.vertex_start], camera);
let expected = dot3(expected, expected).sqrt();
let actual = first_indexed_vertex_distance(&mesh, range.range_index, camera)
.expect("native Op9 primitive distance key");
assert!((actual - expected).abs() < 1e-5);
}
for selector in [1, 2] {
let mut command = command.clone();
write_effect_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector);
let frame = evaluate_f_gener_ball_mesh(
&command,
0,
&identity,
0.5,
0.5,
[0.0, 0.0, 500.0],
AMBIENT_EFFECT_NATIVE_CATEGORY,
)
.expect("Op9 selector frame");
assert!(!ambient_effect_mesh_primitive_layout(&frame)
.expect("selector native quad/cap layout")
.is_empty());
}
}
#[test]
fn mode2_control_effects_repeat_and_reset_at_header_duration() {
assert_eq!(ambient_effect_phase(2, 0, 1.0, 2.0), Some(0.5));
assert_eq!(ambient_effect_phase(2, 0, 2.0, 2.0), Some(0.0));
assert_eq!(ambient_effect_phase(2, 0, 2.25, 2.0), Some(0.125));
assert_eq!(ambient_effect_phase(1, 0, 2.0, 2.0), Some(1.0));
assert_eq!(ambient_effect_phase(0, 0, 0.5, 0.0), Some(0.0));
assert_eq!(ambient_effect_phase(3, 0, 0.5, 2.0), None);
assert_eq!(ambient_effect_phase(2, 0x20, 0.5, 1.0), Some(1.0));
assert_eq!(ambient_effect_phase(2, 0x20, 0.75, 1.0), Some(0.5));
assert_eq!(ambient_effect_phase(1, 0x200, 0.5, 1.0), Some(0.25));
assert_eq!(ambient_effect_phase(1, 0x220, 0.5, 1.0), Some(0.5));
assert_eq!(ambient_effect_phase(1, 0x220, 1.5, 1.0), Some(-2.5));
assert_eq!(ambient_effect_phase(1, 0x20, 1.5, 1.0), Some(-1.0));
}
#[test]
fn control_lamp_accepts_only_supported_bodies_and_keeps_mat0_state() {
assert_eq!(
ambient_effect_command_kind(&endpoint_op3_command()),
Some(AmbientEffectKind::Op3Type2)
);
let mut type0 = endpoint_op3_command();
write_effect_u32(Arc::make_mut(&mut type0.raw_body), 0, 0);
assert_eq!(
ambient_effect_command_kind(&type0),
Some(AmbientEffectKind::Op3Type0)
);
assert!(ambient_effect_render_selector_allows_header(0x10, 0));
assert!(!ambient_effect_render_selector_allows_header(0x800, 0));
assert!(!ambient_effect_render_selector_allows_header(0xD00, 0));
assert!(ambient_effect_render_selector_allows_header(0xD00, 1));
assert!(ambient_effect_render_selector_allows_header(0xD00, 2));
for opcode in [FxOpcode::Op9, FxOpcode::Op4] {
assert_eq!(
ambient_effect_command_kind(&endpoint_gener_ball_command(opcode)),
Some(AmbientEffectKind::GenerBall),
"{opcode:?}"
);
}
for selector in [1, 2] {
let mut op9 = endpoint_gener_ball_command(FxOpcode::Op9);
write_effect_u32(Arc::make_mut(&mut op9.raw_body), 0xC4, selector);
assert_eq!(
ambient_effect_command_kind(&op9),
Some(AmbientEffectKind::GenerBall),
"Op9 selector {selector}"
);
}
let mut invalid_op9 = endpoint_gener_ball_command(FxOpcode::Op9);
write_effect_u32(Arc::make_mut(&mut invalid_op9.raw_body), 0xC4, 3);
assert_eq!(ambient_effect_command_kind(&invalid_op9), None);
assert_eq!(
ambient_effect_command_kind(&endpoint_op8_command(6)),
Some(AmbientEffectKind::SmokePool)
);
for pool_count in [0, 4097] {
assert_eq!(
ambient_effect_command_kind(&endpoint_op8_command(pool_count)),
None
);
}
for raw_mode in [0, 1] {
let op7 = endpoint_op7_command(raw_mode, 1.5, 0.0, 3, 2);
assert_eq!(
ambient_effect_command_kind(&op7),
Some(AmbientEffectKind::Op7ParticlePool),
"raw mode {raw_mode}"
);
assert_eq!(ambient_effect_op7_range_capacity(&op7), Some(6));
}
let accelerated_op7 = endpoint_op7_command(1, 1.5, 0.25, 3, 2);
assert_eq!(
ambient_effect_command_kind(&accelerated_op7),
Some(AmbientEffectKind::Op7ParticlePool)
);
assert_eq!(ambient_effect_op7_range_capacity(&accelerated_op7), Some(6));
for unsupported in [
endpoint_op7_command(2, 1.5, 0.0, 3, 2),
endpoint_op7_command(0, 1.5, f32::NAN, 3, 2),
endpoint_op7_command(0, 0.0, 0.0, 3, 2),
endpoint_op7_command(0, f32::NAN, 0.0, 3, 2),
endpoint_op7_command(0, 1.5, 0.0, u32::MAX, 2),
] {
assert_eq!(ambient_effect_command_kind(&unsupported), None);
}
let state = ambient_effect_pipeline_state(2).expect("native additive MAT0 category");
assert_eq!(state.blend, LegacyBlendMode::Additive);
assert!(state.alpha_test);
assert_eq!(state.depth, LegacyDepthMode::TestReadOnly);
}
#[test]
fn op1_control_lights_use_native_phase_owner_transform_and_flags() {
let mut slot = ControlEffectSlot {
time_mode: 1,
header_flags: 0,
random_instance_key: ControlEffectInstanceKey {
object_index: 0,
component_index: 0,
table_index: 0,
row_index: 0,
},
random_seed: 1,
phase_jitter: 0.0,
random_shift: [0.0; 3],
duration_seconds: 1.0,
owner_object_index: 0,
owner_component_index: 0,
owner_node_index: 0,
owner_component_is_mounted: false,
root_transform: LegacyIron3dEulerTransform {
translation: [10.0, 20.0, 30.0],
orientation_radians: [0.0; 3],
},
owner_scale: [1.0; 3],
mount_pose: None,
local_from_effect: [
[1.0, 0.0, 0.0, 1.0],
[0.0, 1.0, 0.0, 2.0],
[0.0, 0.0, 1.0, 3.0],
[0.0, 0.0, 0.0, 1.0],
],
world_from_effect: [[0.0; 4]; 4],
command_index: 0,
command: endpoint_op1_command(6, 0.5, 1.0),
};
let poses = vec![vec![None]];
let mounts = vec![vec![None]];
let inactive = slot.clone();
let random_samples = HashMap::new();
assert!(update_control_point_lights(
&mut [inactive],
&poses,
&mounts,
0.25,
&random_samples,
false,
)
.is_empty());
let lights = update_control_point_lights(
std::slice::from_mut(&mut slot),
&poses,
&mounts,
0.75,
&random_samples,
false,
);
assert_eq!(lights.len(), 1);
let (light, flags) = lights[0];
assert_eq!(light.position, [13.0, 22.0, 33.0]);
assert_eq!(light.rgb, [0.25, 0.5, 0.75]);
assert_eq!(light.range, 15.0);
assert_eq!(light.coefficients, [1.0, 0.5, 0.25]);
assert_eq!(flags, 0xA000_0000);
assert_eq!(
[
slot.world_from_effect[0][3],
slot.world_from_effect[1][3],
slot.world_from_effect[2][3]
],
[11.0, 22.0, 33.0]
);
slot.header_flags = 0x29;
slot.random_seed = 0x1234_5678;
slot.phase_jitter = 0.4;
slot.random_shift = [2.0, 4.0, 6.0];
let sibling_slots = [slot.clone(), slot.clone()];
let mut states = HashMap::new();
let first = advance_control_effect_random_states(
&mut states,
&[],
&sibling_slots,
&[],
SceneSampleId::Replay(7),
150,
1.5,
);
assert_eq!(first.len(), 1);
let key = slot.random_instance_key;
let first_sample = first[&key];
assert_eq!(first_sample.phase_jitter, 0.0, "the -1 phase skips jitter");
assert_ne!(first_sample.accumulated_shift, [0.0; 3]);
let mut expected_state = ControlEffectRandomState::new(
slot.random_seed,
slot.header_flags,
slot.phase_jitter,
slot.random_shift,
);
assert_eq!(
first_sample,
expected_state.advance(SceneSampleId::Replay(7), 150, -1.0)
);
assert_eq!(
advance_control_effect_random_states(
&mut states,
&[],
&sibling_slots,
&[],
SceneSampleId::Replay(7),
150,
1.5,
)[&key],
first_sample,
"a repeated read of the same captured update reuses the shared sample"
);
let second = advance_control_effect_random_states(
&mut states,
&[],
&sibling_slots,
&[],
SceneSampleId::Replay(8),
200,
0.5,
);
assert_eq!(
second[&key],
expected_state.advance(SceneSampleId::Replay(8), 200, 1.0)
);
assert_ne!(second[&key].phase_jitter, first_sample.phase_jitter);
assert_ne!(
second[&key].accumulated_shift,
first_sample.accumulated_shift
);
assert_eq!(states[&key].last_update_clock_ms, Some(200));
let negative_non_sentinel =
ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("post-duration transformed phase");
assert_eq!(negative_non_sentinel, -2.5);
let mut negative_state = ControlEffectRandomState::new(
slot.random_seed,
0x229,
slot.phase_jitter,
slot.random_shift,
);
assert_eq!(
native_command_phase(0x229, negative_non_sentinel, 0.4),
0.0,
"a phase below -1 clamps to zero rather than using the -1 sentinel"
);
assert_ne!(
negative_state
.advance(SceneSampleId::Live(1), 150, negative_non_sentinel)
.phase_jitter,
0.0,
"a non-sentinel negative phase still consumes the jitter sample"
);
}
#[test]
fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() {
let light = |position, active| VulkanPointLight {
position,
rgb: [0.25, 0.5, 0.75],
range: 15.0,
coefficients: [1.0, 0.5, 0.25],
active,
};
let raw_records = vec![
(light([1.0, 2.0, 3.0], true), 0xA000_0000),
(light([4.0, 5.0, 6.0], true), 0x2000_0000),
(light([7.0, 8.0, 9.0], true), 0x8000_0000),
(light([10.0, 11.0, 12.0], false), 0x0000_0000),
];
let ordinary = ordinary_control_point_lights(&raw_records);
assert_eq!(ordinary.len(), 1);
assert_eq!(ordinary[0].position, [4.0, 5.0, 6.0]);
assert_eq!(raw_records.len(), 4);
assert_eq!(
raw_records
.iter()
.map(|(_, flags)| *flags)
.collect::<Vec<_>>(),
vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0]
);
assert!(ordinary_control_point_lights(&[]).is_empty());
assert!(
ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)])
.is_empty()
);
}
#[test]
fn op1_control_slot_accepts_supported_bodies_with_instance_random_flags() {
let command = endpoint_op1_command(6, 0.0, 1.0);
assert!(ambient_effect_op1_supported(&command));
assert!(!ambient_effect_op1_supported(&endpoint_op1_command(
4, 0.0, 1.0
)));
}
#[test]
fn op1_phase_preserves_native_sentinel_and_initial_manager_gate() {
assert_eq!(native_command_phase(0, -1.0, 0.4), -1.0);
assert_eq!(native_command_phase(0x100, 0.625, 0.0), 0.625);
assert_eq!(native_command_phase(0x80, 0.625, 0.4), 0.0);
assert_eq!(native_command_phase(0x81, -1.0, 0.4), 0.4);
assert_eq!(native_command_phase(0, -0.25, 0.0), 0.0);
assert_eq!(native_command_phase(0, 1.25, 0.0), 1.0);
assert_eq!(native_command_phase(1, 0.5, 0.25), 0.75);
assert_eq!(native_command_phase(1, 0.9, 0.25), 1.0);
let flags_200_20 =
ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("triangular post-duration phase");
assert_eq!(flags_200_20, -2.5);
assert_eq!(native_command_phase(0x220, flags_200_20, 0.4), 0.0);
let raw_minus_one =
ambient_effect_phase(1, 0x20, 1.5, 1.0).expect("triangular sentinel phase");
assert_eq!(raw_minus_one, -1.0);
assert_eq!(native_command_phase(0x21, raw_minus_one, 0.4), -1.0);
assert_eq!(native_command_phase(0xA1, raw_minus_one, 0.4), 0.4);
}
#[test]
fn op7_variable_frames_clear_inactive_draw_ranges() {
let aligned = align_ambient_effect_frames(
vec![endpoint_effect_frame(3), endpoint_effect_frame(7)],
4,
true,
)
.expect("variable Op7 output fits its fixed range allocation");
assert_eq!(aligned.len(), 4);
assert_eq!(
aligned[0].as_ref().map(|frame| frame.command_index),
Some(3)
);
assert_eq!(
aligned[1].as_ref().map(|frame| frame.command_index),
Some(7)
);
assert!(aligned[2].is_none() && aligned[3].is_none());
assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0)], 4, false,).is_err());
assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0); 5], 4, true,).is_err());
}
#[test]
fn active_control_fx_vertex_color_uses_sampled_mat0_additive_rgb() {
let mut slot = AmbientEffectGpuSlot {
kind: AmbientEffectKind::Op3Type2,
time_mode: 1,
header_flags: 0,
random_instance_key: ControlEffectInstanceKey {
object_index: 0,
component_index: 0,
table_index: 0,
row_index: 0,
},
random_seed: 1,
phase_jitter: 0.0,
random_shift: [0.0; 3],
owner_object_index: 0,
owner_component_index: 0,
owner_node_index: 0,
owner_component_is_mounted: false,
root_transform: LegacyIron3dEulerTransform {
translation: [0.0; 3],
orientation_radians: [0.0; 3],
},
owner_scale: [1.0; 3],
mount_pose: None,
local_from_effect: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
control_source: String::new(),
control_table_index: 0,
control_row_index: 0,
control_point_indices: [0; 3],
control_point_owners: [0; 3],
command: endpoint_op3_command(),
command_index: 0,
duration_seconds: 1.0,
world_from_effect: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
range: EnvironmentGpuRange {
range_index: 0,
vertex_start: 0,
vertex_capacity: 4,
material_index: 0,
},
additional_particle_ranges: vec![EnvironmentGpuRange {
range_index: 1,
vertex_start: 4,
vertex_capacity: 4,
material_index: 1,
}],
material_animation_index: 0,
additional_particle_material_animation_indices: vec![1],
smoke_emitter_state: None,
op7_emitter_state: None,
};
assert_eq!(
ambient_effect_ranges(&slot)
.iter()
.map(|(range, binding)| (range.range_index, *binding))
.collect::<Vec<_>>(),
vec![(0, 0), (1, 1)]
);
let mut mesh = VulkanStaticMesh {
vertices: vec![environment_vertex([0.0; 3], [1.0; 3], [0.0; 2]); 8],
indices: Vec::new(),
draw_ranges: Vec::new(),
};
let updates = [
AmbientEffectGeometryUpdate {
range_index: 0,
index_count: 6,
command_rgb: [1.0; 3],
},
AmbientEffectGeometryUpdate {
range_index: 1,
index_count: 6,
command_rgb: [1.0; 3],
},
];
let mut active_materials = HashMap::from([
(
0,
ActiveMaterialState {
material_index: 0,
phase_index: 0,
diffuse_alpha: 1.0,
additive_rgb: [0.9882353, 1.0, 0.16862746],
},
),
(
1,
ActiveMaterialState {
material_index: 1,
phase_index: 0,
diffuse_alpha: 1.0,
additive_rgb: [0.9882353, 1.0, 0.16862746],
},
),
]);
apply_ambient_effect_material_tints(
&[slot.clone()],
&mut mesh,
&updates,
&active_materials,
)
.expect("apply sampled yellow MAT0 color");
assert!(mesh
.vertices
.iter()
.all(|vertex| vertex.color == [0.9882353, 1.0, 0.16862746]));
for material in active_materials.values_mut() {
material.additive_rgb = [0.1, 0.7, 0.2];
}
slot.kind = AmbientEffectKind::SmokePool;
apply_ambient_effect_material_tints(&[slot], &mut mesh, &updates, &active_materials)
.expect("apply next sampled MAT0 color");
assert!(mesh
.vertices
.iter()
.all(|vertex| vertex.color == [0.1, 0.7, 0.2]));
}
#[test]
fn ambient_fx_uses_vertex_opacity_without_multiplying_mat0_opacity() {
let mat0_opacity = ambient_effect_material_alpha(true, 0.4);
let effect_opacity = 0.5;
let texture_alpha = 0.25;
assert_eq!(mat0_opacity, 1.0);
assert_eq!(mat0_opacity * effect_opacity, 0.5);
assert_eq!(texture_alpha * mat0_opacity * effect_opacity, 0.125);
assert_eq!(ambient_effect_material_alpha(false, 0.4), 0.4);
}
#[test]
fn material_animation_cpu_packet_uses_sampled_phase_without_renderer() {
let phase = |additive_red| MaterialPhase {
parameters: [
additive_red,
0,
0,
100,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
texture_raw: [0; 16],
};
let document = Mat0Document {
version: 0,
animation_block_count: 1,
phases: vec![phase(0), phase(255)],
prefix: Vec::new(),
header_opaque: [0; 2],
animation_blocks: vec![MaterialAnimationBlock {
header_raw: 0,
keys: vec![
MaterialKey {
k0: 0,
k1: 500,
k2: 0,
},
MaterialKey {
k0: 1,
k1: 1000,
k2: 0,
},
],
bytes: Vec::new(),
}],
};
let mut binding = MaterialAnimationBinding {
initial_phase_index: 0,
normalized_phase_override: Some(0.75),
ambient_fx_material_alpha: true,
variants: Vec::new(),
base: MaterialPhaseBinding {
document,
animation_block_index: 0,
wear_row_start_ms: 0,
random_state: 0,
frozen_phase_index: None,
phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2],
},
detail: None,
overlay: None,
overlay_detail: None,
stage_textures: None,
};
let effect_sample = sample_material_animation_base(&mut binding, 0)
.expect("sample the effect's native normalized MAT0 phase");
assert_eq!(effect_sample.phase_index, 1);
assert_eq!(effect_sample.coefficients.additive_rgb, [1.0, 0.0, 0.0]);
binding.normalized_phase_override = None;
let world_sample = sample_material_animation_base(&mut binding, 0)
.expect("sample ordinary clock-driven material phase");
assert_eq!(world_sample.phase_index, 0);
assert_eq!(world_sample.coefficients.additive_rgb, [0.0, 0.0, 0.0]);
binding.variants.push(MaterialVariantSet {
phase_material_indices: vec![0, 0],
range_indices: vec![7],
pipeline_category: 0,
});
let mut materials = Vec::new();
append_environment_gradient_material(&mut materials)
.expect("prepare test material without a renderer");
let update =
sample_material_animations(std::slice::from_mut(&mut binding), &materials, 500)
.expect("produce a CPU material-frame packet without a renderer");
assert_eq!(update.active_materials[&7].material_index, 0);
assert_eq!(update.active_materials[&7].phase_index, 1);
assert_eq!(update.range_selections[0].range_index, 7);
}
#[test]
fn ambient_fx_tint_requires_a_proven_ambient_mat0_phase() {
assert!(ambient_effect_tint_supported(
&[false, false],
&[[0.0; 3], [0.2; 3]],
&[[1.0, 1.0, 1.0], [0.2, 0.4, 0.8]],
));
assert!(!ambient_effect_tint_supported(&[], &[], &[]));
assert!(!ambient_effect_tint_supported(&[false], &[], &[]));
assert!(!ambient_effect_tint_supported(
&[true],
&[[0.0; 3]],
&[[1.0; 3]]
));
assert!(!ambient_effect_tint_supported(
&[false],
&[[0.1, 0.0, 0.0]],
&[[0.0; 3]],
));
assert!(!ambient_effect_tint_supported(
&[false],
&[[0.0; 3]],
&[[f32::NAN, 1.0, 1.0]]
));
}
#[test]
fn ambient_fx_ranges_are_excluded_from_world_alpha_and_count_updates() {
let fixed_ranges = HashSet::from([1, 4, 7]);
let world_ranges = world_draw_range_indices(9, &fixed_ranges);
assert_eq!(world_ranges, vec![0, 2, 3, 5, 6, 8]);
assert!(!world_ranges.contains(&7));
}
#[test]
fn ambient_control_rows_accept_unconditional_low_words_but_skip_state_gates_and_chains() {
let mut row = PreparedControlEffectRow {
table_index: 2,
row_index: 0,
raw_condition: [0, 7, 9],
point_offsets: [0, 1, 2],
selector: 0,
resource: fparkan_assets::PreparedControlResourcePair {
archive: [0; 32],
name: [0; 32],
},
};
assert!(ambient_effect_row_unconditionally_active(&row));
for condition_flag in [0x1000_0000, 0x2000_0000, 0x4000_0000, 0x8000_0000] {
row.raw_condition[0] = condition_flag;
assert!(!ambient_effect_row_unconditionally_active(&row));
}
}
}