#![forbid(unsafe_code)] //! `FParkan` render-planning composition root. 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::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::{ 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 serde::Deserialize; 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::>(); 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 { 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 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 environment = load_environment( &args.root, &args.mission, atmosphere_seconds, hold_schedule_phase, )?; 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 { 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 { let mut min = [f32::INFINITY; 3]; let mut max = [f32::NEG_INFINITY; 3]; for vertex in &mesh.vertices { for axis in 0..3 { let value = vertex.position[axis]; if !value.is_finite() { return Err("world preview contains a non-finite vertex".to_string()); } min[axis] = min[axis].min(value); max[axis] = max[axis].max(value); } } if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { return Err("world preview has no finite bounds".to_string()); } Self::from_bounds(min, max) } fn from_bounds(min: [f32; 3], max: [f32; 3]) -> Result { if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { return Err("free-flight bounds must be finite".to_string()); } let center = [ (min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5, ]; let radius = (0..3) .map(|axis| (max[axis] - min[axis]).abs()) .fold(1.0_f32, f32::max); if !radius.is_finite() || radius <= f32::EPSILON { return Err("free-flight bounds are degenerate".to_string()); } let position = [ center[0], center[1] - radius * 2.5, center[2] + radius * 1.25, ]; let direction = sub3(center, position); let distance = length3(direction) .ok_or_else(|| "free-flight initial camera has no direction to the map".to_string())?; let yaw = direction[1].atan2(direction[0]); let pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin(); Ok(Self { position, yaw, pitch, 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 { 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, 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 { 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, 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, material_animation_index: usize, additional_particle_material_animation_indices: Vec, smoke_emitter_state: Option, op7_emitter_state: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct AmbientEffectMeshPrimitive { vertex_start: usize, vertex_count: usize, index_start: usize, index_count: usize, } #[derive(Clone, Debug)] struct ControlPointLightSlot { 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, local_from_effect: [[f32; 4]; 4], world_from_effect: [[f32; 4]; 4], command_index: usize, command: FxCommand, } #[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, last_update_clock_ms: Option, } 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, phase_uv_transforms: Vec<[f32; 4]>, phase_native_flag_40: Vec, 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, phases: Vec, } /// One source MAT0 document retained for frame-time sampling. The renderer /// receives the selected coefficients through its dynamic material state; /// keeping this binding beside the prepared scene avoids rebuilding assets or /// reopening archives on every redraw. #[derive(Clone, Debug)] struct MaterialPhaseBinding { document: Mat0Document, animation_block_index: usize, wear_row_start_ms: u32, random_state: u32, frozen_phase_index: Option, phase_uv_transforms: Vec<[f32; 4]>, } /// Texture phases for the material stages that do not select the draw /// variant. Terrain MAT0 rows are independent: Land1 base, Land2 detail, /// Land1 overlay, and Land2 overlay-detail can each have their own animation /// timeline. Keeping these handles beside the binding lets the renderer /// update the three descriptor images in place without materialising every /// Cartesian product of phase indices. #[derive(Clone, Debug)] struct MaterialStageTextures { detail: Option>, overlay: Option>, overlay_detail: Option>, applied_material_phases: HashMap, } impl MaterialPhaseBinding { fn sample( &mut self, material_index: usize, clock_ms: u32, ) -> Result { if let Some(phase_index) = self.frozen_phase_index { let phase_index = phase_index.min(self.document.phases.len().saturating_sub(1)); let phase = self.document.phases.get(phase_index).ok_or_else(|| { format!( "sample MAT0 material {material_index} at clock {clock_ms}ms: no material phases" ) })?; return Ok(MaterialPhaseSample { phase_index, coefficients: phase.coefficients(), texture_raw: phase.texture_raw, }); } // GetPhase's random mode asks the CRT generator for one fresh value // per query. Animated loop/ping-pong/clamp modes do not consume the // RNG and receive the sampler's ignored value. let block = self .document .animation_blocks .get(self.animation_block_index) .or_else(|| self.document.animation_blocks.first()); let random_value = if block.is_some_and(|block| block.header_raw & 7 == 3) { self.random_state = self .random_state .wrapping_mul(214_013) .wrapping_add(2_531_011); (self.random_state >> 16) & 0x7fff } else { 0 }; sample_material_phase( &self.document, self.animation_block_index, clock_ms, self.wear_row_start_ms, random_value, ) .map_err(|error| { format!( "sample MAT0 material {} at clock {clock_ms}ms: {error}", material_index ) }) } fn sample_normalized( &self, material_index: usize, normalized_phase: f32, ) -> Result { 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, range_indices: Vec, pipeline_category: u8, } #[derive(Clone, Debug)] struct MaterialAnimationBinding { initial_phase_index: usize, normalized_phase_override: Option, ambient_fx_material_alpha: bool, variants: Vec, base: MaterialPhaseBinding, detail: Option, overlay: Option, overlay_detail: Option, stage_textures: Option, } #[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 { 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, ) -> 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, 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, range_selections: Vec, properties: Vec, stage_phases: Vec, } fn sample_material_animations( bindings: &mut [MaterialAnimationBinding], materials: &[VulkanStaticMaterial], clock_ms: u32, ) -> Result { 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(batch_flags: u16, type16_present: bool) -> Option { if type16_present || batch_flags & 0x0008 == 0 { return None; } (batch_flags & 0x0040 != 0 && batch_flags & 0x0010 == 0).then_some(0.0) } 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, ) -> 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)> { bindings .iter() .enumerate() .find_map(|(binding_index, binding)| { binding.variants.iter().find_map(|variant| { (variant .phase_material_indices .get(binding.initial_phase_index) .copied() == Some(material_index)) .then(|| (binding_index, variant.phase_material_indices.clone())) }) }) } fn selected_material_variant_index( variant: &MaterialVariantSet, phase_index: usize, ) -> Option { variant.phase_material_indices.get(phase_index).copied() } fn remap_material_animation_ranges( bindings: &mut [MaterialAnimationBinding], remap: &[usize], ) -> Result<(), String> { for binding in bindings { for variant in &mut binding.variants { for range_index in &mut variant.range_indices { *range_index = *remap .get(*range_index) .ok_or_else(|| "MAT0 draw range remap is out of bounds".to_string())?; } } } Ok(()) } fn phase_texture_name(phase: &MaterialPhase) -> Option { let length = phase .texture_raw .iter() .position(|byte| *byte == 0) .unwrap_or(phase.texture_raw.len()); (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec())) } fn phase_uv_transforms_from_assets( document: &Mat0Document, assets: &MissionAssets, ) -> Vec<[f32; 4]> { document .phases .iter() .map(|phase| { phase_texture_name(phase) .and_then(|name| { assets .textures .iter() .find(|texture| { texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == name }) .and_then(|texture| { page_uv_transform( texture.texm.width(), texture.texm.height(), &texture.texm.page_rects(), i8::from_ne_bytes([phase.parameters[17]]), ) .ok() }) }) .unwrap_or([0.0, 0.0, 1.0, 1.0]) }) .collect() } fn phase_uv_transforms_from_standalone( loader: &mut StandaloneWearMaterialLoader, wear_path: &Path, material_index: u16, document: &Mat0Document, ) -> Result, String> { document .phases .iter() .enumerate() .map(|(phase_index, phase)| { if phase_texture_name(phase).is_none() { return Ok([0.0, 0.0, 1.0, 1.0]); } loader .load( wear_path, material_index, u16::try_from(phase_index).unwrap_or(u16::MAX), ) .map(|(_, prepared)| prepared.page_uv_transform) }) .collect() } #[derive(Clone, Copy, Debug)] struct EnvironmentSkyLayer { kind: SkyPassKind, stage: Option, secondary_stage: Option, material_index: usize, diffuse_alpha: f32, static_directional_rgb: [f32; 3], static_additive_rgb: [f32; 3], translation_z: f32, range: EnvironmentGpuRange, } #[derive(Clone, Copy, Debug)] struct EnvironmentSpriteLayer { row: usize, material_index: usize, diffuse_alpha: f32, flare_index: Option, range: EnvironmentGpuRange, } /// Fixed-capacity GPU allocations for the CPU-owned sky and environment /// output. The Vulkan renderer only receives one shared vertex update and /// smaller draw counts each frame; descriptor images and index allocations /// stay stable while the camera and weather move. #[derive(Clone, Debug)] struct EnvironmentGpuScene { material_animations: Vec, sky_layers: Vec, sprites: Vec, rain: EnvironmentGpuRange, snow: EnvironmentGpuRange, lightning: EnvironmentGpuRange, lightning_material_index: usize, } fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> LegacyPipelineState { LegacyPipelineState { blend, depth, ..LegacyPipelineState::default() } } fn 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, label: &str, ) -> Result { let first = mips .first() .ok_or_else(|| format!("{label} has no mip zero"))?; Ok(VulkanStaticTexture { width: first.width, height: first.height, rgba8: first.rgba8.clone(), mip_levels: mips .into_iter() .map(|mip| VulkanStaticTextureMip { width: mip.width, height: mip.height, rgba8: mip.rgba8, }) .collect(), }) } fn append_environment_material( materials: &mut Vec, material_names: &mut HashMap, material_assets: &HashMap, material_animations: &mut Vec, name: &str, fallback_row: usize, ) -> Result { if let Some(index) = material_names.get(name).copied() { return Ok(index); } let asset = material_assets.get(name).ok_or_else(|| { format!("environment material {name:?} is missing from resolved material.lib/MAT0 assets") })?; let phases = if asset.phases.is_empty() { vec![asset.phase] } else { asset.phases.clone() }; let first_material_index = materials.len(); let mut phase_material_indices = Vec::with_capacity(phases.len()); for (phase_index, phase) in phases.iter().enumerate() { let index = materials.len(); let material_index = u16::try_from(index).map_err(|_| { "environment exceeds the available 16-bit material selector space".to_string() })?; materials.push(VulkanStaticMaterial { material_index, 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, cache: &mut HashMap, ) -> Result { 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, ) -> 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, ) -> Result { let index = materials.len(); let material_index = u16::try_from(index).map_err(|_| { "environment exceeds the available 16-bit material selector space".to_string() })?; materials.push(VulkanStaticMaterial { material_index, 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) -> Result { let material_index = u16::try_from(materials.len()).map_err(|_| { "shadow material selector exceeds the available 16-bit material selector space".to_string() })?; let index = materials.len(); materials.push(VulkanStaticMaterial { material_index, 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 { [ environment_vertex_with_alpha([0.0, 0.0, 0.0], [1.0; 3], 0.0, [0.0, 0.0]), environment_vertex_with_alpha([1.0, 0.0, 0.0], [1.0; 3], 0.0, [1.0, 0.0]), environment_vertex_with_alpha([0.0, 1.0, 0.0], [1.0; 3], 0.0, [0.0, 1.0]), environment_vertex_with_alpha([1.0, 1.0, 0.0], [1.0; 3], 0.0, [1.0, 1.0]), ] .to_vec() } fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle { let normal = normalize3(cross3( sub3(positions[1], positions[0]), sub3(positions[2], positions[0]), )) .unwrap_or([0.0, 0.0, 1.0]); ShadowTriangle { positions, normal, mask, } } fn terrain_shadow_receivers( terrain: &fparkan_terrain_format::LandMeshDocument, ) -> Result, String> { 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 { 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, 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::, _>>()?; 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>], mount_poses: &[Vec>], ) -> 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::>(); 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 { 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, node1_pose: Option, ) -> Option { if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= 0.0 { return None; } let center = mount_pose.map_or(center, |parent_pose| { // The native mounted-unit sphere query keeps node 1's accumulated // rotation but replaces its translation with the accumulated parent // translation. This is deliberately different from the render mount // composition, which also carries node 1's translation. let rotation = node1_pose.map_or(parent_pose.rotation, |node_pose| { multiply_unit_quaternions(parent_pose.rotation, node_pose.rotation) }); add3( parent_pose.translation, rotate_unit_vector(center, rotation), ) }); center .iter() .all(|value| value.is_finite()) .then_some(ShadowComponentSphere { center, radius }) } fn aggregate_shadow_caster( id: u32, native_kind: u32, transform: LegacyIron3dEulerTransform, scale: [f32; 3], triangles: Vec, component_spheres: &[ShadowComponentSphere], ) -> Option { if triangles.is_empty() { return None; } let weight = component_spheres .iter() .map(|sphere| sphere.radius) .sum::(); if !weight.is_finite() || weight <= f32::EPSILON { return None; } // Native 9510 aggregates visible root/EXTO spheres by radius-weighted // center, then encloses every component sphere around that center. let aggregate_center = component_spheres.iter().fold([0.0; 3], |sum, sphere| { add3(sum, scale3(sphere.center, sphere.radius)) }); let aggregate_center = scale3(aggregate_center, weight.recip()); let radius = component_spheres .iter() .map(|sphere| length3(sub3(sphere.center, aggregate_center)).unwrap_or(0.0) + sphere.radius) .fold(0.0, f32::max); let max_scale = scale.iter().copied().fold(f32::NEG_INFINITY, f32::max); if !aggregate_center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= f32::EPSILON || !max_scale.is_finite() || max_scale <= 0.0 { return None; } // The native scale flag applies to the aggregate exactly once. The // mission Euler transform then rotates/translates that scaled sphere. let scaled_center = [ aggregate_center[0] * scale[0], aggregate_center[1] * scale[1], aggregate_center[2] * scale[2], ]; let center = transform.try_transform_scaled_point(scaled_center, [1.0; 3])?; let radius = radius * max_scale; if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() { return None; } let world_axes = transform .try_row_major() .map(|matrix| { [ [matrix[0], matrix[4], matrix[8]], [matrix[1], matrix[5], matrix[9]], [matrix[2], matrix[6], matrix[10]], ] }) .unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); Some(ShadowCaster { id, world_axes, sphere: ShadowSphere { center, radius }, native_kind, triangles, }) } fn shadow_lights( sky_frame: Option<&SkyFrame<'_>>, environment_frame: Option<&EnvironmentFrame>, renderer: &VulkanSmokeRenderer, control_point_lights: &[(VulkanPointLight, u32)], ) -> Vec { let mut lights = Vec::new(); if let Some(sky_frame) = sky_frame { lights.extend(sky_frame.directional_lights.into_iter().enumerate().map( |(index, light)| { ShadowLight::Directional { direction: light.direction, rgb: light.color, active: light.active, // CSun's constructor marks both the sun (slot 0) and // moon (slot 2) as primary. ShadowReference selects the // first one for kind 3 and accumulates both active // primary directions for ordinary objects (kind 4). flags: (index == 0 || index == 2) .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) .unwrap_or(0), } }, )); } else { let uniforms = renderer.frame_uniforms(); lights.extend( uniforms .directional_lights .into_iter() .enumerate() .map(|(index, light)| ShadowLight::Directional { direction: light.direction, rgb: light.rgb, active: light.active, flags: (index == 0 || index == 2) .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) .unwrap_or(0), }), ); } if let Some(environment_frame) = environment_frame { lights.extend( environment_frame .point_lights .iter() .map(|light| ShadowLight::Point { position: light.position, rgb: [light.color[0], light.color[1], light.color[2]], range: light.range, coefficients: light.attenuation, active: light.active, flags: 0, }), ); } else { let point = renderer.frame_uniforms().point_light; lights.push(ShadowLight::Point { position: point.position, rgb: point.rgb, range: point.range, coefficients: point.coefficients, active: point.active, flags: 0, }); } lights.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 { casters .iter() .filter(|caster| native_shadow_caster_visible(caster, clip_from_world)) .cloned() .collect() } fn append_shadow_frame_geometry( mesh: &mut VulkanStaticMesh, shadow_range: EnvironmentGpuRange, shadow_base_vertex: usize, shadow_base_index: usize, frame: &ShadowFrame, ) -> Result { if shadow_base_vertex > mesh.vertices.len() || shadow_base_index > mesh.indices.len() { return Err("shadow dynamic base is outside the shared mesh".to_string()); } mesh.vertices.truncate(shadow_base_vertex); mesh.indices.truncate(shadow_base_index); let vertex_base = u32::try_from(mesh.vertices.len()) .map_err(|_| "shadow vertex base exceeds u32".to_string())?; let first_index = u32::try_from(mesh.indices.len()) .map_err(|_| "shadow index base exceeds u32".to_string())?; if frame.indices.iter().any(|&index| { usize::try_from(index) .ok() .is_none_or(|index| index >= frame.vertices.len()) }) { return Err("shadow frame index exceeds its vertex stream".to_string()); } mesh.vertices.extend(frame.vertices.iter().map(|vertex| { environment_vertex_with_alpha_normal( vertex.position, [1.0; 3], vertex.alpha.clamp(0.0, 1.0), vertex.uv, vertex.normal, ) })); mesh.indices.extend( frame .indices .iter() .map(|&index| { vertex_base .checked_add(index) .ok_or_else(|| "shadow index exceeds u32".to_string()) }) .collect::, _>>()?, ); let index_count = u32::try_from(frame.indices.len()) .map_err(|_| "shadow index count exceeds u32".to_string())?; if let Some(range) = mesh.draw_ranges.get_mut(shadow_range.range_index) { range.first_index = first_index; range.index_count = index_count; } Ok(VulkanDynamicDrawRange { range_index: shadow_range.range_index, first_index, index_count, }) } fn shadow_atlas_texture_from_frame(frame: &ShadowFrame) -> VulkanStaticTexture { VulkanStaticTexture { width: 256, height: 256, rgba8: frame.atlas_rgba8.clone(), mip_levels: vec![VulkanStaticTextureMip { width: 256, height: 256, rgba8: frame.atlas_rgba8.clone(), }], } } fn append_environment_range( mesh: &mut VulkanStaticMesh, material_index: usize, vertices: Vec, local_indices: Vec, pipeline_state: LegacyPipelineState, ) -> Result { if vertices.is_empty() || local_indices.is_empty() || !local_indices.len().is_multiple_of(3) { return Err("environment geometry range is empty or not triangle-aligned".to_string()); } let vertex_start = mesh.vertices.len(); let first_index = mesh.indices.len(); let material_index = u16::try_from(material_index) .map_err(|_| "environment material selector exceeds u16".to_string())?; let vertex_start_u32 = u32::try_from(vertex_start) .map_err(|_| "environment vertex allocation exceeds u32".to_string())?; for index in local_indices { mesh.indices.push( vertex_start_u32 .checked_add(index) .ok_or_else(|| "environment index exceeds u32".to_string())?, ); } let index_count = u32::try_from(mesh.indices.len() - first_index) .map_err(|_| "environment index allocation exceeds u32".to_string())?; let vertex_capacity = vertices.len(); mesh.vertices.extend(vertices); let first_index = u32::try_from(first_index) .map_err(|_| "environment first index exceeds u32".to_string())?; let range_index = mesh.draw_ranges.len(); mesh.draw_ranges .push(fparkan_render_vulkan::VulkanStaticDrawRange { first_index, index_count, material_index, lightmap_index: u8::MAX, batch_flags: 0, pipeline_state, 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, 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::, _>>() }) .collect::, _>>()?; for (dynamic, ranges) in dynamic_casters.iter_mut().zip(remapped) { dynamic.component_draw_range_indices = ranges; } Ok(()) } fn quad_indices(slot_count: usize) -> Result, String> { let mut indices = Vec::with_capacity( slot_count .checked_mul(6) .ok_or_else(|| "environment quad index count overflow".to_string())?, ); for slot in 0..slot_count { let base = u32::try_from( slot.checked_mul(4) .ok_or_else(|| "environment quad vertex count overflow".to_string())?, ) .map_err(|_| "environment quad vertex count exceeds u32".to_string())?; indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 1, base + 3]); } Ok(indices) } fn placeholder_quad_vertices( camera: &PreviewCameraFrame, slot_count: usize, size: f32, ) -> Vec { let center = add3( camera.position, scale3(camera.forward(), camera.far_plane * 0.5), ); let right = scale3(camera.right(), size); let up = scale3(camera.up(), size); (0..slot_count) .flat_map(|_| { [ environment_vertex(sub3(sub3(center, right), up), [1.0; 3], [0.0, 1.0]), environment_vertex(add3(sub3(center, up), right), [1.0; 3], [1.0, 1.0]), environment_vertex(add3(sub3(center, right), up), [1.0; 3], [0.0, 0.0]), environment_vertex(add3(add3(center, right), up), [1.0; 3], [1.0, 0.0]), ] }) .collect() } fn screen_gradient_quad_vertices( camera: &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 { 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 { 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, type16_present_ranges: &mut HashSet, materials: &mut Vec, preview_material_animations: &mut Vec, ambient_effect_slots: &mut [AmbientEffectGpuSlot], dynamic_shadow_casters: &mut [DynamicShadowCaster], sun_occlusion_range_indices: &mut Vec, environment_materials: &SkyMaterials, material_assets: &HashMap, sky_mesh: &SkyMesh, camera: &PreviewCameraFrame, shadow_range_index: &mut usize, ) -> Result { let mut names = HashMap::new(); let mut material_animations = Vec::new(); let mut material_indices = [None; 9]; let fallback_names = SkyMaterials::default(); for row in 0..9 { let name = environment_materials .material_name(row) .or_else(|| fallback_names.material_name(row)) .unwrap_or("ENVIRONMENT"); let index = append_environment_material( materials, &mut names, material_assets, &mut material_animations, name, row, )?; material_indices[row] = Some(index); } let gradient_material_index = append_environment_gradient_material(materials)?; let nebula_material_index = material_indices[0] .ok_or_else(|| "nebula material selector is unavailable".to_string())?; let stars_material_index = material_indices[1] .ok_or_else(|| "stars material selector is unavailable".to_string())?; // Native sky mode 4 uses one descriptor set: TEX0 is the nebula and // TEX1 is the independently animated stars texture whose alpha chooses // the contribution. Keep the stars MAT0 binding as the nebula binding's // detail stage; do not form a Cartesian product of the two phase rows. let (nebula_binding_index, nebula_phase_materials) = find_material_animation_variant(&material_animations, nebula_material_index) .ok_or_else(|| { format!("MAT0 sky material {nebula_material_index} has no phase variants") })?; let (stars_binding_index, stars_phase_materials) = find_material_animation_variant(&material_animations, stars_material_index) .ok_or_else(|| { format!("MAT0 sky material {stars_material_index} has no phase variants") })?; if stars_phase_materials.is_empty() { return Err("stars material has no phase variants".to_string()); } let stars_descriptors = stars_phase_materials .iter() .map(|&material_index| { let material = materials.get(material_index).ok_or_else(|| { format!("stars material descriptor {material_index} is unavailable") })?; Ok((material.texture.clone(), material.uv_transform)) }) .collect::, String>>()?; let stars_binding = material_animations .get(stars_binding_index) .ok_or_else(|| "stars material animation binding is unavailable".to_string())? .base .clone(); let stars_stage_textures = stars_descriptors .iter() .map(|(texture, _)| texture.clone()) .collect(); let stars_initial_uv = stars_descriptors .first() .map(|(_, uv_transform)| *uv_transform) .unwrap_or([0.0, 0.0, 1.0, 1.0]); for &material_index in &nebula_phase_materials { let stars_texture = stars_descriptors .first() .map(|(texture, _)| texture.clone()) .ok_or_else(|| "stars material descriptor is unavailable".to_string())?; let nebula = materials.get_mut(material_index).ok_or_else(|| { format!("nebula material descriptor {material_index} is unavailable") })?; nebula.detail_texture = Some(stars_texture); nebula.detail_uv_transform = stars_initial_uv; nebula.sky_nebula_stars = true; } let nebula_binding = material_animations .get_mut(nebula_binding_index) .ok_or_else(|| "nebula material animation binding is unavailable".to_string())?; nebula_binding.detail = Some(stars_binding); nebula_binding.stage_textures = Some(MaterialStageTextures { detail: Some(stars_stage_textures), overlay: None, overlay_detail: None, // Force TEX1 to the independently sampled stars phase on the first // frame, including when the initial star descriptor is already set. applied_material_phases: HashMap::new(), }); let lightning_material_index = append_environment_material( materials, &mut names, material_assets, &mut material_animations, "env_lightning", 3, )?; let mut scene = Self { material_animations, sky_layers: Vec::with_capacity(4), sprites: Vec::with_capacity(ENVIRONMENT_SPRITE_ROWS.len() + ENVIRONMENT_FLARE_SLOTS), rain: EnvironmentGpuRange { range_index: 0, vertex_start: 0, vertex_capacity: 0, material_index: material_indices[8].unwrap_or(0), }, snow: EnvironmentGpuRange { range_index: 0, vertex_start: 0, vertex_capacity: 0, material_index: material_indices[7].unwrap_or(0), }, lightning: EnvironmentGpuRange { range_index: 0, vertex_start: 0, vertex_capacity: 0, material_index: lightning_material_index, }, lightning_material_index, }; let screen_range = append_environment_range( mesh, gradient_material_index, screen_gradient_quad_vertices(camera, [16.0, 9.0], [1.0; 3]).to_vec(), quad_indices(1)?, world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::Disabled), )?; scene.sky_layers.push(EnvironmentSkyLayer { kind: SkyPassKind::ScreenGradient, stage: None, secondary_stage: None, material_index: gradient_material_index, diffuse_alpha: 1.0, static_directional_rgb: [1.0; 3], static_additive_rgb: [0.0; 3], translation_z: 0.0, range: screen_range, }); let nebula_material = materials .get(nebula_material_index) .ok_or_else(|| "nebula material descriptor is unavailable".to_string())?; let nebula_vertices = sky_mesh .vertices .iter() .map(|vertex| { let color = vertex.color_rgba(); environment_vertex_with_alpha_normal( add3(vertex.position, camera.position), [color[0], color[1], color[2]], color[3], vertex.uv[0], sky_normal(vertex.normal), ) }) .collect(); let nebula_range = append_environment_range( mesh, nebula_material_index, nebula_vertices, sky_mesh.indices.clone(), world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), )?; register_material_range( &mut scene.material_animations, nebula_material_index, nebula_range.range_index, ); scene.sky_layers.push(EnvironmentSkyLayer { kind: SkyPassKind::NebulaStars, stage: Some(0), secondary_stage: Some(1), material_index: nebula_material_index, diffuse_alpha: nebula_material.diffuse_alpha, static_directional_rgb: nebula_material.directional_rgb, static_additive_rgb: nebula_material.additive_rgb, translation_z: 0.0, range: nebula_range, }); let dome_vertices = sky_mesh .vertices .iter() .map(|vertex| { let color = vertex.color_rgba(); environment_vertex_with_alpha_normal( add3(vertex.position, camera.position), [color[0], color[1], color[2]], color[3], [0.0, 0.0], sky_normal(vertex.normal), ) }) .collect(); let dome_range = append_environment_range( mesh, gradient_material_index, dome_vertices, sky_mesh.indices.clone(), world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), )?; scene.sky_layers.push(EnvironmentSkyLayer { kind: SkyPassKind::DomeGradient, stage: None, secondary_stage: None, material_index: gradient_material_index, diffuse_alpha: 1.0, static_directional_rgb: [1.0; 3], static_additive_rgb: [0.0; 3], translation_z: 0.0, range: dome_range, }); let clouds_material_index = material_indices[2] .ok_or_else(|| "cloud material selector is unavailable".to_string())?; let clouds_material = materials .get(clouds_material_index) .ok_or_else(|| "cloud material descriptor is unavailable".to_string())?; let clouds_vertices = sky_mesh .vertices .iter() .map(|vertex| { let color = vertex.color_rgba(); environment_vertex_with_alpha_normal( add3(add3(vertex.position, camera.position), [0.0, 0.0, -5000.0]), [color[0], color[1], color[2]], color[3], vertex.uv[2], sky_normal(vertex.normal), ) }) .collect(); let clouds_range = append_environment_range( mesh, clouds_material_index, clouds_vertices, sky_mesh.indices.clone(), world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), )?; register_material_range( &mut scene.material_animations, clouds_material_index, clouds_range.range_index, ); scene.sky_layers.push(EnvironmentSkyLayer { kind: SkyPassKind::Clouds, stage: Some(2), secondary_stage: None, material_index: clouds_material_index, diffuse_alpha: clouds_material.diffuse_alpha, static_directional_rgb: clouds_material.directional_rgb, static_additive_rgb: clouds_material.additive_rgb, translation_z: -5000.0, range: clouds_range, }); let sprite_size = (camera.far_plane * 0.01).max(1.0); for row in ENVIRONMENT_SPRITE_ROWS { let material_index = material_indices[row] .ok_or_else(|| format!("sky sprite row {row} has no material selector"))?; let range = append_environment_range( mesh, material_index, placeholder_quad_vertices(camera, 1, sprite_size), quad_indices(1)?, world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), )?; register_material_range( &mut scene.material_animations, material_index, range.range_index, ); scene.sprites.push(EnvironmentSpriteLayer { row, material_index, diffuse_alpha: materials .get(material_index) .map_or(1.0, |material| material.diffuse_alpha), flare_index: None, range, }); } for flare_index in 0..ENVIRONMENT_FLARE_SLOTS { let row = SKY_FLARE_ROWS[flare_index]; let material_index = material_indices[row] .ok_or_else(|| format!("sky flare row {row} has no material selector"))?; let range = append_environment_range( mesh, material_index, placeholder_quad_vertices(camera, 1, sprite_size), quad_indices(1)?, world_pipeline_state(LegacyBlendMode::Additive, LegacyDepthMode::TestReadOnly), )?; register_material_range( &mut scene.material_animations, material_index, range.range_index, ); scene.sprites.push(EnvironmentSpriteLayer { row, material_index, diffuse_alpha: materials .get(material_index) .map_or(1.0, |material| material.diffuse_alpha), flare_index: Some(flare_index), range, }); } let particle_indices = quad_indices(ENVIRONMENT_PARTICLE_SLOTS)?; scene.rain = append_environment_range( mesh, material_indices[8] .ok_or_else(|| "rain material selector is unavailable".to_string())?, placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), particle_indices.clone(), world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), )?; register_material_range( &mut scene.material_animations, scene.rain.material_index, scene.rain.range_index, ); scene.snow = append_environment_range( mesh, material_indices[7] .ok_or_else(|| "snow material selector is unavailable".to_string())?, placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), particle_indices, world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), )?; register_material_range( &mut scene.material_animations, scene.snow.material_index, scene.snow.range_index, ); scene.lightning = append_environment_range( mesh, lightning_material_index, placeholder_quad_vertices(camera, 1, sprite_size), quad_indices(1)?, world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), )?; register_material_range( &mut scene.material_animations, scene.lightning.material_index, scene.lightning.range_index, ); // Preserve the native sky order while keeping the indexed mesh // contiguous. The screen gradient must be the first draw, and the // cloud dome follows all directional sprites. Reordering the range // metadata alone would leave first_index values pointing into the old // segment order, so move each index segment together with its range. let range_for_kind = |kind: SkyPassKind| { scene .sky_layers .iter() .find(|layer| layer.kind == kind) .map(|layer| layer.range.range_index) }; let screen_index = range_for_kind(SkyPassKind::ScreenGradient) .ok_or_else(|| "screen gradient range was not allocated".to_string())?; let nebula_index = range_for_kind(SkyPassKind::NebulaStars) .ok_or_else(|| "nebula sky range was not allocated".to_string())?; let dome_index = range_for_kind(SkyPassKind::DomeGradient) .ok_or_else(|| "dome gradient range was not allocated".to_string())?; let cloud_index = range_for_kind(SkyPassKind::Clouds) .ok_or_else(|| "cloud sky range was not allocated".to_string())?; let sprite_indices = scene .sprites .iter() .map(|sprite| sprite.range.range_index) .collect::>(); if sprite_indices.is_empty() { return Err("sky sprite ranges were not allocated".to_string()); } let rain_index = scene.rain.range_index; let snow_index = scene.snow.range_index; let lightning_index = scene.lightning.range_index; let environment_indices = [ screen_index, nebula_index, dome_index, cloud_index, rain_index, snow_index, lightning_index, *shadow_range_index, ] .into_iter() .chain(sprite_indices.iter().copied()) .collect::>(); let old_range_count = mesh.draw_ranges.len(); let mut old_order = Vec::with_capacity(old_range_count); old_order.push(screen_index); old_order.extend((0..old_range_count).filter(|index| { !environment_indices.contains(index) && *index != screen_index && *index != nebula_index && *index != dome_index && *index != cloud_index })); old_order.push(*shadow_range_index); old_order.extend([nebula_index, dome_index, 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> { 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 { 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, schedule_offset_seconds: f32, time_seconds: f32, fixed_schedule_seconds: Option, fixed_phase_reported: bool, } 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, effect_updates: Vec, world_sort_keys: Vec<(usize, f32)>, ambient_sort_keys: Vec<(usize, f32)>, material_update: MaterialFrameUpdate, environment_material_update: Option, environment: Option, } impl DynamicEnvironment { fn update_cpu( &mut self, dt_seconds: f32, camera: &PreviewCameraFrame, aspect: f32, viewport: [f32; 2], mut audio: Option<&mut audio::GameAudio>, ) -> Result { 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(); 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, hold_schedule_phase: bool, ) -> Result, String> { let mission_dir = mission_asset_directory(root, mission)?; let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else { return Ok(None); }; let sky_bytes = std::fs::read(&sky_path) .map_err(|error| format!("read atmosphere {}: {error}", sky_path.display()))?; let schedule = TypedAtmosphere::parse(&sky_bytes) .map_err(|error| format!("parse atmosphere {}: {error}", sky_path.display()))?; let sky_wea_path = match find_sibling_file(&sky_path, "sky.wea") { Some(path) => Some(path), None => find_mission_file(&mission_dir, "sky.wea")?, } .ok_or_else(|| format!("selected atmosphere {} has no sky.wea", sky_path.display()))?; let materials = SkyMaterials::parse( &std::fs::read(&sky_wea_path) .map_err(|error| format!("read sky materials {}: {error}", sky_wea_path.display()))?, ) .map_err(|error| format!("parse sky materials {}: {error}", sky_wea_path.display()))?; let mut sky_material_loader = StandaloneWearMaterialLoader::new(root); let mut material_assets = load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root( root, &sky_wea_path, 0, ) .map_err(|error| { format!( "resolve sky material WEAR {}: {error}", sky_wea_path.display() ) })? .into_iter() .enumerate() .map( |(material_index, (name, mips, phase, document))| -> Result<_, String> { let initial_phase = phase; let mut phase_textures = Vec::with_capacity(document.phases.len()); let mut phases = Vec::with_capacity(document.phases.len()); for phase_index in 0..document.phases.len() { let phase_index_u16 = u16::try_from(phase_index).unwrap_or(u16::MAX); let (phase_mips, sampled_phase) = if phase_index == 0 { (mips.clone(), initial_phase) } else { sky_material_loader .load( &sky_wea_path, u16::try_from(material_index).unwrap_or(u16::MAX), phase_index_u16, ) .map_err(|error| { format!("resolve sky material phase {phase_index}: {error}") })? }; phase_textures.push(vulkan_texture_from_rgba_mips(phase_mips, "sky material")?); phases.push(sampled_phase); } let texture = phase_textures .first() .cloned() .ok_or_else(|| "sky material has no MAT0 phases".to_string())?; Ok(( String::from_utf8_lossy(&name).into_owned(), EnvironmentMaterialAsset { texture, phase: initial_phase, document, phase_textures, phases, }, )) }, ) .collect::, String>>()?; let effect_bytes = load_resource_entry_bytes_from_root(root, "effects.rlb", "env_lightning") .map_err(|error| format!("load effects.rlb/env_lightning: {error}"))?; let lightning_effect = decode_env_lightning_fxid(effect_bytes) .map_err(|error| format!("decode effects.rlb/env_lightning: {error}"))?; if !lightning_effect .visual .archive .eq_ignore_ascii_case("material.lib") { return Err(format!( "env_lightning visual archive must be material.lib, got {:?}", lightning_effect.visual.archive )); } let (lightning_mips, lightning_phase, lightning_document) = load_material_name_texture_rgba8_and_phase_with_document_from_root( root, &lightning_effect.visual.name, 0, ) .map_err(|error| { format!( "resolve env_lightning MAT0 {}: {error}", lightning_effect.visual.name ) })?; let mut lightning_phase_textures = vec![vulkan_texture_from_rgba_mips( lightning_mips, "env_lightning material", )?]; let mut lightning_phases = vec![lightning_phase]; for phase_index in 1..lightning_document.phases.len() { let (phase_mips, phase, _document) = load_material_name_texture_rgba8_and_phase_with_document_from_root( root, &lightning_effect.visual.name, u16::try_from(phase_index).unwrap_or(u16::MAX), ) .map_err(|error| { format!( "resolve env_lightning MAT0 phase {phase_index} {}: {error}", lightning_effect.visual.name ) })?; lightning_phase_textures.push(vulkan_texture_from_rgba_mips( phase_mips, "env_lightning material", )?); lightning_phases.push(phase); } let lightning_texture = lightning_phase_textures .first() .cloned() .ok_or_else(|| "env_lightning MAT0 has no phases".to_string())?; material_assets.insert( lightning_effect.visual.name.clone(), EnvironmentMaterialAsset { texture: lightning_texture, phase: lightning_phase, document: lightning_document, phase_textures: lightning_phase_textures, phases: lightning_phases, }, ); let initial_offset_seconds = schedule.initial_offset_seconds() as f32; let sky = SkySystem::new(Default::default(), materials.clone()) .map_err(|error| format!("build sky geometry: {error}"))?; let time_seconds = atmosphere_seconds .map(|seconds| seconds - initial_offset_seconds) .unwrap_or(0.0); let 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, })) } fn environment_sample_time( time_seconds: f32, schedule_offset_seconds: f32, dt_seconds: f32, fixed_schedule_seconds: Option, ) -> (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 { let mission_path = Path::new(mission); if mission_path.is_absolute() { return Err("mission environment path must be relative to --root".to_string()); } let directory = mission_path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); let directory = root.join(directory); if !directory.is_dir() { return Err(format!( "selected mission asset directory does not exist: {}", directory.display() )); } Ok(directory) } /// Finds one mission-local loose environment file. A direct sibling wins; /// nested `env`, `environment`, or `planet` layouts are accepted only when /// they contain a single matching file, so a second unrelated candidate never /// changes the selected mission silently. fn find_mission_file(root: &Path, name: &str) -> Result, String> { let direct = root.join(name); if direct.is_file() { return Ok(Some(direct)); } let mut stack = vec![root.to_path_buf()]; let mut matches = Vec::new(); while let Some(directory) = stack.pop() { let Ok(entries) = std::fs::read_dir(&directory) else { continue; }; for entry in entries.flatten() { let path = entry.path(); let Ok(file_type) = entry.file_type() else { continue; }; if file_type.is_symlink() { continue; } if file_type.is_dir() { stack.push(path); } else if file_type.is_file() && path .file_name() .is_some_and(|file_name| file_name.eq_ignore_ascii_case(name)) { matches.push(path); } } } matches.sort(); match matches.as_slice() { [] => Ok(None), [path] => Ok(Some(path.clone())), _ => Err(format!( "selected mission has multiple {name} files: {}", matches .iter() .map(|path| path.display().to_string()) .collect::>() .join(", ") )), } } fn find_sibling_file(source: &Path, name: &str) -> Option { source .parent() .map(|parent| parent.join(name)) .filter(|path| path.is_file()) } fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { [left[0] + right[0], left[1] + right[1], left[2] + right[2]] } fn add3_in_place(target: &mut [f32; 3], value: [f32; 3]) { *target = add3(*target, value); } fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { [left[0] - right[0], left[1] - right[1], left[2] - right[2]] } fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] { [value[0] * factor, value[1] * factor, value[2] * factor] } fn 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 { 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, native_handedness_by_range: HashMap, type16_present_ranges: HashSet, materials: Vec, material_animations: Vec, ambient_effect_slots: Vec, control_point_light_slots: Vec, component_pose_buffers: Vec>>, component_mount_poses: Vec>>, dynamic_component_geometry: Vec>>, building_collision_bindings: Vec, control_animation_instances: Vec>>, dynamic_shadow_casters: Vec, sun_occlusion_range_indices: Vec, shadow_casters: Vec, shadow_receivers: Vec, shadow_material_index: usize, terrain: Arc, camera: VulkanStaticCamera, view_camera: PreviewCameraFrame, captured_viewport: Option, free_camera: Option, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, } #[derive(Clone)] struct ControlAnimationInstance { control: PreparedComponentControl, model: ModelAsset, gate_values: Vec>, state: ControlInstanceState, last_sampled_node_times: Vec>, 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, 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, root_transform: LegacyIron3dEulerTransform, owner_scale: [f32; 3], mounted: bool, ) -> Result { 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, ) -> Result, 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, 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, component_spheres: Vec, } #[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) -> bool { index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO) } fn native_sun_object_kind_eligible(source_type: Option) -> bool { native_shadow_kind(source_type).is_some() } const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT"); const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT"); // 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) -> Option { 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 { 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 { 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, 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, 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<[[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>], 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_point_light_world_from_component_pose_with_random_shift( slot: &ControlPointLightSlot, component_pose_buffers: &[Vec>], 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 [ControlPointLightSlot], component_pose_buffers: &[Vec>], component_mount_poses: &[Vec>], elapsed_seconds: f32, random_samples: &HashMap, 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_point_light_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, 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::>(), ); *reported = true; } fn ordinary_control_point_lights( raw_control_lights: &[(VulkanPointLight, u32)], ) -> Vec { 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, mesh: &mut VulkanStaticMesh, materials: &mut Vec, material_animations: &mut Vec, effect_documents: &mut HashMap, effect_materials: &mut HashMap, slots: &mut Vec, point_light_slots: &mut Vec, ) -> 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 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. 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 { 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 { 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) { point_light_slots.push(ControlPointLightSlot { 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(), }); 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, 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 { 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 { 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 { if command.opcode != FxOpcode::Op7 || command.raw_body.len() < 0xCC { return None; } let read_u32 = |offset: usize| -> Option { 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, ambient_slots: &[AmbientEffectGpuSlot], point_light_slots: &[ControlPointLightSlot], sample_id: SceneSampleId, control_clock_ms: u32, elapsed_seconds: f32, ) -> HashMap { 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 { 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::() / 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 { 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>], mesh: &mut VulkanStaticMesh, material_animations: &mut [MaterialAnimationBinding], elapsed_seconds: f32, random_samples: &HashMap, camera_world: [f32; 3], ) -> Result, 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, range_count: usize, variable_pool: bool, ) -> Result>, 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, ) -> 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, ) -> 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, 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 { 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, 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, ) -> 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, 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, ) -> Vec { (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, 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 = std::sync::OnceLock::new(); static TRACKS: std::sync::OnceLock< std::sync::Mutex>, > = 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::() * 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::() .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=, 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, calls: usize, phase_travel: f32, last_phase: Option, 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> = std::sync::OnceLock::new(); static TRACKS: std::sync::OnceLock< std::sync::Mutex>, > = 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::() * 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 { 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>, String> { let mut node_times = vec![Some(AnimationTime(0.0)); node_count]; for binding in bindings { // Disabled rows are filtered while loading CTLD, but preserve the // native guard here in case a caller constructs MissionAssets itself. if binding.flags & 4 != 0 { continue; } if binding.node_index >= node_count { return Err(format!( "control binding node {} is outside model node count {node_count}", binding.node_index )); } if !binding.frame_a.is_finite() || !binding.frame_b.is_finite() || !binding.initial_blend.is_finite() || binding.frame_a < 0.0 || binding.frame_b < 0.0 { return Err(format!( "control binding for node {} contains an invalid frame or blend", binding.node_index )); } let mut blend = binding.initial_blend; if binding.flags & 1 != 0 { if blend > 1.0 { blend -= 1.0; } if blend < 0.0 { blend += 1.0; } } else { blend = blend.clamp(0.0, 1.0); } if binding.flags & 2 != 0 { blend = 1.0 - blend; } let sample_time = (1.0 - blend) * binding.frame_a + blend * binding.frame_b; if !sample_time.is_finite() || sample_time < 0.0 { return Err(format!( "control binding for node {} produced a negative or non-finite sample time unsupported by the static sampler", binding.node_index )); } // Source order is significant: native initialization writes each // enabled row in turn, so a later row for the same node wins. node_times[binding.node_index] = Some(AnimationTime(sample_time)); } Ok(node_times) } fn static_preview_component_pose_buffer( model: &ModelAsset, bindings: &[PreparedControlNodeBinding], static_animation_frame: Option, ) -> Result, String> { if let Some(frame) = static_animation_frame { return Ok(node38_sampled_hierarchy(model, frame)); } let node_times = static_preview_node_times(model.node_count, bindings)?; Ok(node38_sampled_hierarchy_at_times(model, &node_times) .or_else(|| node38_fallback_hierarchy(model))) } fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec> { 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) -> 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 { 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 { 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>], pose_buffers: &[Vec>], 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>], pose_buffers: &mut [Vec>], 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> = 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], ) -> 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>], pose_buffers: &[Vec>], mount_poses: &mut [Vec>], 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, 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, 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 { 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::(); 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), 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::, _>>()? .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::, _>>()? .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, ) -> Result<(VulkanStaticMesh, Vec, 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>, Vec>), 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::::new(); let mut buckets = HashMap::<(i32, i32, i16, i16), Vec>::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::>() { 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::, _>>()?; 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, 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::().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::().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, static_animation_frame: Option, static_material_phase: Option, root: &std::path::Path, land_msh_path: &str, ) -> Result { 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 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::, Arc>::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::>(); 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_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 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::, 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, )?; } } 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, )?; } } 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::>(); 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, native_handedness_by_range, type16_present_ranges, materials, material_animations, ambient_effect_slots, control_point_light_slots, 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, material_animations: &mut Vec, static_material_phase: Option, ) -> Result, 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::>(); 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 = usize::from(static_material_phase.unwrap_or(0)) .min(detail_phases.len().saturating_sub(1)); let initial_overlay_phase_index = overlay_phases.as_ref().map(|phases| { usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1)) }); let initial_overlay_detail_phase_index = overlay_detail_phases.as_ref().map(|phases| { usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1)) }); let mut phase_material_indices = Vec::with_capacity(phase_count); for phase_index in 0..phase_count { let (base, base_phase) = &base_phases[phase_index]; // The base draw selector follows the base MAT0 timeline. The // other descriptor stages start at their own initial phase; // frame-time sampling below can then advance them separately. let (detail, detail_phase) = &detail_phases[initial_detail_phase_index]; let overlay = overlay_phases .as_ref() .zip(initial_overlay_phase_index) .map(|(phases, phase_index)| &phases[phase_index]); let overlay_detail = overlay_detail_phases .as_ref() .zip(initial_overlay_detail_phase_index) .map(|(phases, phase_index)| &phases[phase_index]); let preview_selector = u16::try_from(materials.len()).map_err(|_| { "static preview exceeds the available 16-bit material selector space" .to_string() })?; materials.push(VulkanStaticMaterial { material_index: preview_selector, 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: 0, wear_row_start_ms: 0, random_state: packed_tag as u32 ^ 0x5A5A_A5A5, frozen_phase_index: static_material_phase.map(usize::from), phase_uv_transforms: detail_uvs, }), overlay: overlay_document.map(|document| MaterialPhaseBinding { document, animation_block_index: 0, wear_row_start_ms: 0, random_state: packed_tag as u32 ^ 0xA5A5_5A5A, frozen_phase_index: static_material_phase.map(usize::from), phase_uv_transforms: overlay_uvs, }), overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding { document, animation_block_index: 0, wear_row_start_ms: 0, random_state: packed_tag as u32 ^ 0xC3C3_3C3C, frozen_phase_index: static_material_phase.map(usize::from), phase_uv_transforms: overlay_detail_uvs, }), stage_textures: Some(stage_textures), }); let preview_selector = u16::try_from(materials.len() - phase_count + initial_phase_index) .map_err(|_| "terrain material selector exceeds u16".to_string())?; Ok((packed_tag, preview_selector)) }) .collect() } fn static_preview_component_materials( assets: &MissionAssets, visual: &PreparedVisual, mesh: &VulkanStaticMesh, static_material_phase: Option, materials: &mut Vec, material_animations: &mut Vec, ) -> Result, String> { let mut source_selectors = mesh .draw_ranges .iter() .map(|range| range.material_index) .collect::>(); 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 { let mip_images = (0..texture.texm.mip_count()) .map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX))) .collect::, _>>() .map_err(|err| format!("decode {label} {:?}: {err}", texture.source.name))?; let image = mip_images .first() .ok_or_else(|| format!("{label} {:?} has no mip zero", texture.source.name))?; Ok(VulkanStaticTexture { width: image.width, height: image.height, rgba8: image.rgba8.clone(), mip_levels: mip_images .into_iter() .map(|image| VulkanStaticTextureMip { width: image.width, height: image.height, rgba8: image.rgba8, }) .collect(), }) } fn static_preview_lightmap_textures( assets: &MissionAssets, visual: &PreparedVisual, ) -> Result, String> { visual .lightmap_ids .iter() .map(|id| { let texture = assets .textures .iter() .find(|texture| { texture.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>, ) -> Result<(), String> { append_static_preview_component_with_lightmaps( target, component, selector_remap, None, &[], material_animations, ) } fn append_static_preview_component_with_lightmaps( target: &mut VulkanStaticMesh, component: VulkanStaticMesh, selector_remap: &[(u16, u16)], mut materials: Option<&mut Vec>, lightmap_textures: &[VulkanStaticTexture], mut material_animations: Option<&mut Vec>, ) -> Result<(), String> { let vertex_base = u32::try_from(target.vertices.len()) .map_err(|_| "static preview vertex count exceeds u32".to_string())?; 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::, _>>()?, ); // A lightmapped range must keep the lightmap material family when MAT0 // advances to another phase. The range's original selector identifies // the base binding, while the lightmap selector identifies the secondary // texture. Keep the generated phase family and the binding/variant it // belongs to so update_material_animations can rebind every frame. let mut lightmap_materials: HashMap<(usize, u8), (Vec, usize, usize)> = HashMap::new(); for range in component.draw_ranges { let 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, String> { (0..document.phases.len()) .map(|phase_index| { load_standalone_vulkan_texture( loader, wear_path, material_index, u16::try_from(phase_index) .map_err(|_| "MAT0 phase index exceeds u16".to_string())?, ) }) .collect() } fn run_static_vulkan_mode( preview: StaticPreviewScene, environment: Option, audio: Option, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, readback_sequence_out: Option<&std::path::Path>, enable_validation: bool, control_clock_target_ms: Option, startup_timings: StartupTimings, ) -> Result { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); let mut preview = preview; let mut environment = environment; // Reserve the projected-shadow draw range before the environment builder // reorders its fixed passes. The range itself remains a stable draw slot; // frame geometry is appended after every static environment allocation. let mut shadow_range = append_environment_range( &mut preview.mesh, preview.shadow_material_index, shadow_placeholder_vertices(), quad_indices(1)?, 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, native_handedness_by_range: HashMap, type16_present_ranges: HashSet, materials: Vec, material_animations: Vec, ambient_effect_slots: Vec, control_point_light_slots: Vec, control_effect_random_states: HashMap, component_pose_buffers: Vec>>, component_mount_poses: Vec>>, dynamic_component_geometry: Vec>>, building_collision_bindings: Vec, control_animation_instances: Vec>>, dynamic_shadow_casters: Vec, sun_occlusion_range_indices: Vec, terrain: Arc, world_range_indices: Vec, world_range_index_counts: HashMap, camera: VulkanStaticCamera, view_camera: PreviewCameraFrame, captured_viewport: Option, free_camera: Option, environment: Option, environment_gpu: Option, shadow_range: EnvironmentGpuRange, shadow_base_vertex: usize, shadow_base_index: usize, shadow_material_index: usize, shadow_casters: Vec, shadow_receivers: Vec, shadow_cache: ShadowPageCache, shadow_last_evidence: ShadowFrameEvidence, 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, environment_primitive_count: usize, pressed_keys: HashSet, mouse_look: bool, audio: Option, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, target_frames: u64, control_clock_target_ms: Option, mission: String, object_count: usize, readback_out: Option, readback_sequence_out: Option, enable_validation: bool, startup_timings: StartupTimings, window_id: Option, window: Option, renderer: Option, frames_presented: u64, output: Option, error: Option, } impl StaticVulkanApp { fn new( preview: StaticPreviewScene, environment: Option, environment_gpu: Option, shadow_range: EnvironmentGpuRange, shadow_base_vertex: usize, shadow_base_index: usize, audio: Option, target_frames: u64, mission: &str, object_count: usize, readback_out: Option, readback_sequence_out: Option, enable_validation: bool, control_clock_target_ms: Option, 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, 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_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, ) -> Result<(), String> { for &range_index in &self.world_range_indices { let range = self .mesh .draw_ranges .get(range_index) .ok_or_else(|| "world draw range index is out of bounds".to_string())?; let alpha = self .materials .get(usize::from(range.material_index)) .map_or(1.0, |material| material.diffuse_alpha); let alpha_override = native_msh_diffuse_alpha_override( range.batch_flags, self.type16_present_ranges.contains(&range_index), ); 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}") })?; renderer .set_draw_range_index_count( range_index, 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(), ), ) .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, world_range_index_counts: &HashMap, native_handedness_by_range: &HashMap, eye_world: [f32; 3], active_materials: &HashMap, renderer: &mut VulkanSmokeRenderer, ) -> Result<(), String> { for &range_index in world_range_indices { let range = mesh .draw_ranges .get(range_index) .ok_or_else(|| "world draw range index is out of bounds".to_string())?; let state = active_materials.get(&range_index); let 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( range.batch_flags, type16_present_ranges.contains(&range_index), ); 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(), ); renderer .set_draw_range_index_count(range_index, count) .map_err(|error| format!("update world draw range {range_index} count: {error}"))?; } Ok(()) } fn world_draw_sort_keys(&self, camera_position: [f32; 3]) -> Result, String> { let mut keys = Vec::with_capacity(self.world_range_indices.len()); for &range_index in &self.world_range_indices { let sort_key = first_indexed_vertex_distance(&self.mesh, range_index, camera_position)?; keys.push((range_index, sort_key)); } Ok(keys) } fn update_free_camera(&mut self, 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 { 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, 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, ) -> Result { 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]) .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, 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::() / 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::() / 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}"))?; } let environment = if let Some(environment) = self.environment.as_mut() { Some(environment.update_cpu( clock.delta_seconds, &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, frame.camera.position, &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::>(); 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, &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, &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] ) }, ); 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 { 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) { 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, control_clock_ms: Option, validation: bool, readback_out: Option, readback_sequence_out: Option, preview_roots: NonZeroUsize, legacy_camera_capture: Option, static_animation_frame: Option, static_material_phase: Option, } impl Args { fn parse(args: &[String]) -> Result { 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::() .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, }) } } #[derive(Clone, Debug, Deserialize, PartialEq)] struct LegacyCameraCapture { schema: String, render_input_usable: Option, selector0_words: [u32; 16], parented_camera: Option, effective_world_matrix_space: Option, effective_world_matrix_words: Option<[u32; 16]>, viewport: [i32; 4], near_plane: f32, far_plane: f32, field_of_view_radians: f32, atmosphere_seconds: Option, } fn selected_atmosphere_seconds( command_line: Option, capture: Option<&LegacyCameraCapture>, ) -> Option { command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds)) } impl LegacyCameraCapture { fn preview_matrix_words(&self) -> Result<[u32; 16], String> { match ( self.effective_world_matrix_space.as_deref(), self.effective_world_matrix_words, ) { (Some("world"), Some(words)) => Ok(words), (Some(_), _) => Err( "legacy camera effective_world_matrix_space must be 'world'".to_string(), ), (None, Some(_)) => Err( "legacy camera effective_world_matrix_words requires an explicit world-space marker" .to_string(), ), (None, None) if self.parented_camera == Some(true) => Err( "parented legacy camera requires an effective world-space matrix".to_string(), ), (None, None) => Ok(self.selector0_words), } } fn captured_viewport(&self) -> Result { let width = self.viewport[2] .checked_sub(self.viewport[0]) .filter(|width| *width > 0) .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?; let height = self.viewport[3] .checked_sub(self.viewport[1]) .filter(|height| *height > 0) .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?; Ok(CapturedViewport { rect: self.viewport, extent: [ u32::try_from(width) .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?, u32::try_from(height) .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?, ], }) } fn preview_camera_frame(&self) -> Result { let frame = PreviewCameraFrame::from_legacy_d3d7( RawCameraTransform { words: self.preview_matrix_words()?, }, LegacyD3d7Projection { viewport: self.viewport, near_plane: self.near_plane, far_plane: self.far_plane, field_of_view_radians: self.field_of_view_radians, }, ) .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?; self.captured_viewport()?; Ok(frame) } } fn load_legacy_camera_capture(path: &std::path::Path) -> Result { let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display())) } fn parse_legacy_camera_capture(bytes: &[u8]) -> Result { // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM. // `capture-original-camera.ps1` intentionally emits plain JSON, so accept // that normal hand-off format as well as UTF-8 without making the caller // re-encode a read-only capture file. let json = decode_legacy_camera_capture_json(bytes)?; let capture: LegacyCameraCapture = serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?; if capture.schema != "fparkan-legacy-camera-v1" { return Err("unsupported legacy camera capture schema".to_string()); } if capture.effective_world_matrix_space.is_some() != capture.effective_world_matrix_words.is_some() { return Err( "effective_world_matrix_space and effective_world_matrix_words must be supplied together" .to_string(), ); } if capture.render_input_usable == Some(false) { return Err("legacy camera capture is marked render_input_usable=false".to_string()); } if capture .atmosphere_seconds .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) { return Err( "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(), ); } capture.preview_camera_frame()?; Ok(capture) } fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result { let decode_utf16 = |words: Vec| { String::from_utf16(&words) .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}")) }; match bytes { [0xff, 0xfe, rest @ ..] => { let chunks = rest.chunks_exact(2); if !chunks.remainder().is_empty() { return Err("invalid UTF-16LE legacy camera JSON length".to_string()); } decode_utf16( chunks .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(), ) } [0xfe, 0xff, rest @ ..] => { let chunks = rest.chunks_exact(2); if !chunks.remainder().is_empty() { return Err("invalid UTF-16BE legacy camera JSON length".to_string()); } decode_utf16( chunks .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) .collect(), ) } [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest) .map(str::to_owned) .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), _ => std::str::from_utf8(bytes) .map(str::to_owned) .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), } } fn usage() -> String { "usage: fparkan-game --root --mission [--frames ] [--atmosphere-seconds ] [--control-clock-ms ] [--validation] [--preview-roots ] [--legacy-camera-capture ] [--static-animation-frame ] [--static-material-phase ] [--readback-out ] [--readback-sequence-out ]\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::() * 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, alpha_override: &mut Option| { *alpha_override = native_msh_diffuse_alpha_override(range.batch_flags, false); world_draw_range_index_count( &mesh, range, original_count, alpha_override.unwrap_or(1.0), [0.0; 3], None, ) }; assert_eq!(update(&range, &mut alpha_override), 0); range.batch_flags = 0x0108; assert_eq!(update(&range, &mut alpha_override), original_count); range.batch_flags = 0x0148; assert_eq!(update(&range, &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 { 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, 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::>(); 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::>(); 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 { values.iter().map(|value| (*value).to_string()).collect() } #[test] #[ignore = "requires licensed corpus"] fn licensed_corpus_part1_prepares_every_mission() { prepare_licensed_corpus("FPARKAN_CORPUS_PART1_ROOT", 29); } #[test] #[ignore = "requires licensed corpus"] fn licensed_corpus_part2_prepares_every_mission() { prepare_licensed_corpus("FPARKAN_CORPUS_PART2_ROOT", 31); } fn prepare_licensed_corpus(variable: &str, expected_missions: usize) { let root = std::env::var_os(variable) .map(PathBuf::from) .unwrap_or_else(|| panic!("{variable} is required")); assert!( root.is_dir(), "licensed corpus root is missing: {}", root.display() ); let mut missions = Vec::new(); let mut directories = vec![root.clone()]; while let Some(directory) = directories.pop() { let entries = std::fs::read_dir(&directory) .unwrap_or_else(|error| panic!("read {}: {error}", directory.display())); for entry in entries { let path = entry .unwrap_or_else(|error| { panic!("read entry in {}: {error}", directory.display()) }) .path(); if path.is_dir() { directories.push(path); } else if path .file_name() .is_some_and(|name| name.eq_ignore_ascii_case("data.tma")) { let relative = path .strip_prefix(&root) .expect("mission path is under corpus root") .to_str() .expect("mission path is UTF-8") .replace('\\', "/"); missions.push(relative); } } } missions.sort(); assert_eq!( missions.len(), expected_missions, "installed mission count for {variable}" ); let vfs: Arc = Arc::new(DirectoryVfs::new(&root)); let mut prepared = 0usize; let mut environment_missions = 0usize; for mission in missions { let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); let loaded = load_mission_static_preview_roots( &mut engine, MissionRequest { key: mission.clone(), 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] 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::>() ); 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 native_cpu_shaded_msh_alpha_override_uses_only_the_constant_batch_rule() { assert_eq!(native_msh_diffuse_alpha_override(0x0148, false), Some(0.0)); assert_eq!(native_msh_diffuse_alpha_override(0x0108, false), None); assert_eq!(native_msh_diffuse_alpha_override(0x0118, false), None); assert_eq!(native_msh_diffuse_alpha_override(0x0058, false), None); assert_eq!(native_msh_diffuse_alpha_override(0x0148, true), None); assert_eq!(native_msh_diffuse_alpha_override(0x0058, true), None); assert_eq!(native_msh_diffuse_alpha_override(0x0040, false), 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::>(), [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, ) -> (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 = ControlPointLightSlot { 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, }; 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 { 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], 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, Option, ) { ( 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 ); } #[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_uses_view_dependent_sky_fog_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 fog_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!(fog_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::(); 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::>(); 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_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), }; 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::>(); 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::(), 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 = ControlPointLightSlot { 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![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![(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)); } } }