diff options
Diffstat (limited to 'apps/fparkan-game/src')
| -rw-r--r-- | apps/fparkan-game/src/camera_capture.rs | 1 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 629 | ||||
| -rw-r--r-- | apps/fparkan-game/src/terrain_lod.rs | 2 |
3 files changed, 435 insertions, 197 deletions
diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs index bc16807..dccb738 100644 --- a/apps/fparkan-game/src/camera_capture.rs +++ b/apps/fparkan-game/src/camera_capture.rs @@ -23,6 +23,7 @@ pub(super) struct LegacyCameraCapture { pub(super) struct SkyUvClock { pub(super) verified: bool, pub(super) elapsed_milliseconds: u32, + pub(super) raw_clock: Option<u32>, } #[derive(Clone, Debug, Deserialize, PartialEq)] diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 3432bda..80585e1 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -61,8 +61,9 @@ use fparkan_render::{ 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, + legacy_packet_distance_squared, 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, @@ -131,7 +132,6 @@ fn run(args: &[String]) -> Result<String, String> { .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() })?; @@ -723,6 +723,7 @@ struct AmbientEffectMeshPrimitive { #[derive(Clone, Debug)] struct ControlEffectSlot { + light_owner_scope: u32, time_mode: u32, header_flags: u32, random_instance_key: ControlEffectInstanceKey, @@ -759,9 +760,11 @@ struct ControlEffectInstanceKey { } #[derive(Clone, Copy, Debug, Default, PartialEq)] -struct ControlEffectRandomSample { +struct ControlEffectFrameSample { phase_jitter: f32, accumulated_shift: [f32; 3], + phase: Option<f32>, + elapsed_seconds: f32, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -799,18 +802,19 @@ impl NativeFxRandom16 { } #[derive(Clone, Debug)] -struct ControlEffectRandomState { +struct ControlEffectInstanceState { header_flags: u32, phase_jitter_amplitude: f32, random_shift_amplitude: [f32; 3], phase_and_z_rng: NativeFxRandom16, xy_rng: NativeFxRandom16, - sample: ControlEffectRandomSample, + sample: ControlEffectFrameSample, last_update_id: Option<SceneSampleId>, last_update_clock_ms: Option<u32>, + clock: Option<fparkan_fx::clock::FxInstanceClock>, } -impl ControlEffectRandomState { +impl ControlEffectInstanceState { fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self { Self { header_flags, @@ -818,9 +822,10 @@ impl ControlEffectRandomState { random_shift_amplitude: random_shift, phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3), xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595), - sample: ControlEffectRandomSample::default(), + sample: ControlEffectFrameSample::default(), last_update_id: None, last_update_clock_ms: None, + clock: None, } } @@ -829,7 +834,7 @@ impl ControlEffectRandomState { sample_id: SceneSampleId, control_clock_ms: u32, initial_phase: f32, - ) -> ControlEffectRandomSample { + ) -> ControlEffectFrameSample { // 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. @@ -1374,7 +1379,7 @@ fn native_msh_diffuse_alpha_override( let distance = if range.batch_flags & 0x0050 == 0x0040 { 0.0 } else { - // shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available. + // The native alpha callback reads the input packet before renderer clipping. first_indexed_vertex_distance(mesh, range_index, eye_world)? }; legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov) @@ -2753,6 +2758,7 @@ fn append_environment_range( let range_index = mesh.draw_ranges.len(); mesh.draw_ranges .push(fparkan_render_vulkan::VulkanStaticDrawRange { + owner_scope: 0, first_index, index_count, material_index, @@ -2760,6 +2766,7 @@ fn append_environment_range( batch_flags: 0, pipeline_state, terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, }); Ok(EnvironmentGpuRange { @@ -3978,10 +3985,7 @@ fn first_indexed_vertex_distance( .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) + legacy_packet_distance_squared(dot3(delta, delta)) .ok_or_else(|| "world draw range sort distance is non-finite".to_string()) } @@ -4637,6 +4641,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { } struct StaticPreviewScene { + fixed_water_clock_ms: Option<u32>, mesh: VulkanStaticMesh, dynamic_vertex_start: usize, terrain_lod: Option<terrain_lod::TerrainLodDraws>, @@ -5311,8 +5316,7 @@ fn update_control_point_lights( slots: &mut [ControlEffectSlot], component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], component_mount_poses: &[Vec<Option<VulkanNodePose>>], - elapsed_seconds: f32, - random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>, + random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectFrameSample>, emit_diagnostic: bool, ) -> Vec<(VulkanPointLight, u32)> { let mut lights = Vec::new(); @@ -5324,10 +5328,10 @@ fn update_control_point_lights( .copied() .flatten(); } - let random_sample = random_samples - .get(&slot.random_instance_key) - .copied() - .unwrap_or_default(); + let Some(random_sample) = random_samples.get(&slot.random_instance_key).copied() else { + eprintln!("render_coverage control_fx_instance_sample_missing=1 (point light)"); + continue; + }; let Some(world_from_effect) = control_effect_world_from_component_pose_with_random_shift( slot, component_pose_buffers, @@ -5336,13 +5340,9 @@ fn update_control_point_lights( 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 { + let Some(phase) = random_sample.phase.map(|phase| { + native_command_phase(slot.header_flags, phase, random_sample.phase_jitter) + }) else { continue; }; let matrix = slot.world_from_effect; @@ -5386,6 +5386,11 @@ fn update_control_point_lights( } lights.push(( VulkanPointLight { + owner_scope: if frame.native_flags & 0x8000_0000 != 0 { + slot.light_owner_scope + } else { + 0 + }, position: frame.position, rgb: [frame.color[0], frame.color[1], frame.color[2]], range: frame.range, @@ -5431,12 +5436,12 @@ fn report_control_point_lights_once( *reported = true; } -fn ordinary_control_point_lights( +fn active_control_point_lights( raw_control_lights: &[(VulkanPointLight, u32)], ) -> Vec<VulkanPointLight> { raw_control_lights .iter() - .filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0) + .filter(|(light, _)| light.active) .map(|(light, _)| *light) .collect() } @@ -5496,6 +5501,13 @@ fn append_initial_control_effect_slots( let Some(point_list) = control.point_list.as_ref() else { return Ok(()); }; + let light_owner_scope = u32::try_from(object_index) + .ok() + .and_then(|index| index.checked_add(1)) + .ok_or_else(|| "Control light owner exceeds GPU scope range".to_string())?; + let initial_range_count = mesh.draw_ranges.len(); + let mut unsupported_commands = 0; + let mut state_dependent_commands = 0; for row in selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section) { @@ -5509,10 +5521,22 @@ fn append_initial_control_effect_slots( ) })?; let key = format!("{archive}/{name}"); + let mut record_skip = |reason: &str, + count: usize, + needs_state: bool, + command: Option<usize>| { + if needs_state { + state_dependent_commands += count; + } else { + unsupported_commands += count; + } + eprintln!("unexpanded Control FX reason:{reason} count:{count} needs_game_state:{needs_state} object:{object_index} component:{component_index} source:{key} table:{} row:{} command:{command:?}", row.table_index, row.row_index); + }; 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 { + eprintln!("render_coverage control_fx_invalid_documents=1 (object:{object_index} component:{component_index} source:{key})"); continue; }; effect_documents.insert(key.clone(), document); @@ -5529,17 +5553,24 @@ fn append_initial_control_effect_slots( 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) + || !(duration_seconds * 1000.0).is_finite() || !header.phase_jitter.is_finite() || !header.random_shift.iter().all(|value| value.is_finite()) || !header.scale.iter().all(|value| value.is_finite()) { - // Control can reference action effects whose header timing and - // scale conventions are outside this ambient subset. Leave them - // unexpanded until their lifetime and transform contract are - // understood rather than failing the whole mission preview. *unsupported_sounds += sound_commands; + record_skip("header_values", document.commands().len(), false, None); + continue; + } + if !matches!(header.time_mode, 0..=3) || (header.time_mode != 0 && duration_seconds <= 0.0) + { + *unsupported_sounds += sound_commands; + record_skip( + "time_mode", + document.commands().len(), + fparkan_fx::fx_time_mode_requires_owner_state(header.time_mode), + None, + ); continue; } let owner_node_index = @@ -5552,6 +5583,7 @@ fn append_initial_control_effect_slots( }); let Some(owner_node_index) = owner_node_index else { *unsupported_sounds += sound_commands; + record_skip("owner_node", document.commands().len(), false, None); continue; }; let owner_node_pose = control_effect_owner_node_pose( @@ -5561,6 +5593,7 @@ fn append_initial_control_effect_slots( ); let Some(owner_node_pose) = owner_node_pose else { *unsupported_sounds += sound_commands; + record_skip("owner_pose", document.commands().len(), false, None); continue; }; let root_transform = LegacyIron3dEulerTransform { @@ -5599,6 +5632,7 @@ fn append_initial_control_effect_slots( for (command_index, command) in document.commands().iter().enumerate() { if ambient_effect_op1_supported(command) || command.opcode == FxOpcode::Op2 { let slot = ControlEffectSlot { + light_owner_scope, time_mode: header.time_mode, header_flags: header.flags, random_instance_key, @@ -5627,6 +5661,7 @@ fn append_initial_control_effect_slots( }); } else { *unsupported_sounds += 1; + record_skip("sound_body", 1, false, Some(command_index)); eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)"); } } else { @@ -5635,6 +5670,7 @@ fn append_initial_control_effect_slots( continue; } let Some(kind) = ambient_effect_command_kind(command) else { + record_skip("opcode_or_body", 1, false, Some(command_index)); continue; }; if !ambient_effect_render_selector_allows_header( @@ -5644,11 +5680,13 @@ fn append_initial_control_effect_slots( continue; } let Some(reference) = command.resource_refs.first() else { + record_skip("material_reference", 1, false, Some(command_index)); 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() { + record_skip("empty_material_reference", 1, false, Some(command_index)); continue; } let cache_key = format!("{material_archive}/{material_name}"); @@ -5669,6 +5707,7 @@ fn append_initial_control_effect_slots( &prepared.phase_directional_rgb, &prepared.phase_additive_rgb, ) { + record_skip("material_tint", 1, false, Some(command_index)); continue; } let first_material = *prepared @@ -5686,6 +5725,7 @@ fn append_initial_control_effect_slots( [0.0; 3], AMBIENT_EFFECT_NATIVE_CATEGORY, ) else { + record_skip("mesh_template", 1, false, Some(command_index)); continue; }; Some(template) @@ -5820,6 +5860,10 @@ fn append_initial_control_effect_slots( }); } } + for range in &mut mesh.draw_ranges[initial_range_count..] { + range.owner_scope = light_owner_scope; + } + eprintln!("render_coverage control_fx_unsupported={unsupported_commands} control_fx_requires_game_state={state_dependent_commands}"); Ok(()) } @@ -5984,65 +6028,70 @@ fn control_effect_instance_seed( ) } -fn advance_control_effect_random_states( - states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>, +fn advance_control_effect_instances( + states: &mut HashMap<ControlEffectInstanceKey, ControlEffectInstanceState>, ambient_slots: &[AmbientEffectGpuSlot], point_light_slots: &[ControlEffectSlot], sound_slots: &[ControlSoundSlot], sample_id: SceneSampleId, control_clock_ms: u32, - elapsed_seconds: f32, -) -> HashMap<ControlEffectInstanceKey, ControlEffectRandomSample> { +) -> HashMap<ControlEffectInstanceKey, ControlEffectFrameSample> { 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, + slot.time_mode, + slot.duration_seconds, )); } for slot in point_light_slots .iter() .chain(sound_slots.iter().map(|slot| &slot.effect)) { - let initial_phase = ambient_effect_phase( - slot.time_mode, - slot.header_flags, - elapsed_seconds, - slot.duration_seconds, - ) - .map(|phase| initial_native_effect_phase(slot.header_flags, phase)) - .unwrap_or(-1.0); specifications.entry(slot.random_instance_key).or_insert(( slot.random_seed, slot.header_flags, slot.phase_jitter, slot.random_shift, - initial_phase, + slot.time_mode, + slot.duration_seconds, )); } specifications .into_iter() .map( - |(key, (seed, flags, phase_jitter, random_shift, initial_phase))| { + |(key, (seed, flags, phase_jitter, random_shift, mode, duration))| { let state = states.entry(key).or_insert_with(|| { - ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift) + ControlEffectInstanceState::new(seed, flags, phase_jitter, random_shift) }); - ( - key, - state.advance(sample_id, control_clock_ms, initial_phase), - ) + if state.last_update_id == Some(sample_id) { + return (key, state.sample); + } + let clock = state.clock.get_or_insert_with(|| { + if flags & 0x180 != 0 { + eprintln!("render_coverage control_fx_manager_flag_stub=1 (preview manager A8=0; instance={key:?})"); + } + // All static-preview instances are created at scene time zero. + fparkan_fx::clock::FxInstanceClock::new(mode, flags, duration, 0.0) + .expect("prepared FX clock metadata") + }); + let now_ms = control_clock_ms as f32; + let phase = clock.update(now_ms).and_then(|()| clock.phase(now_ms, false)); + let instance_elapsed = clock.elapsed_seconds(now_ms).unwrap_or(0.0); + if phase.is_none() && state.last_update_id.is_none() { + eprintln!("render_coverage control_fx_clock_unsupported=1 (instance={key:?} mode={mode})"); + } + let initial_phase = phase + .map(|phase| initial_native_effect_phase(flags, phase)) + .unwrap_or(-1.0); + state.advance(sample_id, control_clock_ms, initial_phase); + state.sample.phase = phase; + state.sample.elapsed_seconds = instance_elapsed; + (key, state.sample) }, ) .collect() @@ -6174,8 +6223,7 @@ fn update_ambient_effect_geometry( component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], mesh: &mut VulkanStaticMesh, material_animations: &mut [MaterialAnimationBinding], - elapsed_seconds: f32, - random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>, + random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectFrameSample>, camera_world: [f32; 3], ) -> Result<Vec<AmbientEffectGeometryUpdate>, String> { let mut updates = Vec::with_capacity(slots.len()); @@ -6183,7 +6231,13 @@ fn update_ambient_effect_geometry( let random_sample = random_samples .get(&slot.random_instance_key) .copied() - .unwrap_or_default(); + .ok_or_else(|| { + format!( + "Control FX instance sample missing: {:?}", + slot.random_instance_key + ) + })?; + let elapsed_seconds = random_sample.elapsed_seconds; let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift( slot, component_pose_buffers, @@ -6193,13 +6247,9 @@ fn update_ambient_effect_geometry( 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 { + let Some(phase) = random_sample.phase.map(|phase| { + native_command_phase(slot.header_flags, phase, random_sample.phase_jitter) + }) else { deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; continue; }; @@ -6920,42 +6970,19 @@ fn fx_material_speed_diagnostic( }; } +#[cfg(test)] fn ambient_effect_phase( time_mode: u32, header_flags: u32, elapsed_seconds: f32, duration_seconds: f32, ) -> Option<f32> { - if !elapsed_seconds.is_finite() - || elapsed_seconds < 0.0 - || !duration_seconds.is_finite() - || duration_seconds < 0.0 - { + if !elapsed_seconds.is_finite() || elapsed_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) + fparkan_fx::clock::FxInstanceClock::new(time_mode, header_flags, duration_seconds, 0.0)? + .phase(elapsed_seconds * 1000.0, false) } - 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]); @@ -7606,7 +7633,7 @@ fn project_basement_foundation( 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 { + let (material_index, base_only, reflection, 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}") @@ -7654,6 +7681,7 @@ fn project_basement_foundation( ( face.material_tag, fragment.surface_mask.0 & 0x400 == 0, + fragment.surface_mask.0 & 0x20000 != 0, vertices, ) } @@ -7671,7 +7699,7 @@ fn project_basement_foundation( fragment_uv0_words, fragment_normals, ); - (0xff00, true, vertices) + (0xff00, true, false, vertices) } }; @@ -7684,7 +7712,13 @@ fn project_basement_foundation( 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.material_index == material_index + && range.terrain_base_only == base_only + && range.terrain_reflection == reflection + && (!reflection + || vertices[0].position[2] + == mesh.vertices[mesh.indices[range.first_index as usize] as usize] + .position[2]) }) { range.index_count = range .index_count @@ -7692,6 +7726,7 @@ fn project_basement_foundation( .ok_or_else(|| "basement terrain draw range exceeds u32".to_string())?; } else { mesh.draw_ranges.push(VulkanStaticDrawRange { + owner_scope: 0, first_index, index_count: 3, material_index, @@ -7699,6 +7734,7 @@ fn project_basement_foundation( batch_flags: 0, pipeline_state, terrain_base_only: base_only, + terrain_reflection: reflection, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, }); } @@ -8004,9 +8040,9 @@ fn static_preview_mesh_and_materials( let unverified_special_faces = terrain_mesh .faces .iter() - .filter(|face| face.flags.0 & 8 != 0) + .filter(|face| face.flags.0 & 8 != 0 && face.flags.0 & 0x20000 == 0) .count(); - eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (source faces across LODs use the ordinary terrain shader; native special pass and reflection/EMBM mapping pending)"); + eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (non-reflective source faces across LODs; native special-pass mapping pending)"); let footprint_sources = mission_basement_footprints(assets, roots)?; let foundation = if footprint_sources.is_empty() { None @@ -8384,6 +8420,10 @@ fn static_preview_mesh_and_materials( .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.owner_scope = u32::try_from(object_index) + .ok() + .and_then(|index| index.checked_add(1)) + .ok_or_else(|| "MSH light owner exceeds GPU scope range".to_string())?; range.pipeline_state.cull = native_msh_cull_mode(range.batch_flags, component_handedness); } @@ -8524,9 +8564,6 @@ fn static_preview_mesh_and_materials( 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 { @@ -8545,7 +8582,23 @@ fn static_preview_mesh_and_materials( }; let camera = view_camera.vulkan_camera(); let shadow_material_index = append_shadow_material(&mut materials)?; + let fixed_water_clock_ms = legacy_camera.as_ref().and_then(|capture| { + capture + .sky_uv_clock + .as_ref() + .and_then(|clock| clock.raw_clock) + }); + if legacy_camera.is_some() + && fixed_water_clock_ms.is_none() + && mesh + .draw_ranges + .iter() + .any(|range| range.terrain_reflection) + { + eprintln!("render_coverage captured_water_phase_unverified=1 (capture has no verified raw queue clock; using replay elapsed time)"); + } Ok(StaticPreviewScene { + fixed_water_clock_ms, mesh, dynamic_vertex_start, terrain_lod, @@ -9360,6 +9413,7 @@ fn run_static_vulkan_mode( } struct StaticVulkanApp { + fixed_water_clock_ms: Option<u32>, mesh: VulkanStaticMesh, dynamic_vertex_start: usize, terrain_lod: Option<terrain_lod::TerrainLodDraws>, @@ -9371,7 +9425,7 @@ struct StaticVulkanApp { control_point_light_slots: Vec<ControlEffectSlot>, control_sound_slots: Vec<ControlSoundSlot>, unsupported_control_sounds: usize, - control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>, + control_effect_instance_states: HashMap<ControlEffectInstanceKey, ControlEffectInstanceState>, component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>, component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>, @@ -9479,7 +9533,7 @@ impl StaticVulkanApp { control_point_light_slots: preview.control_point_light_slots, control_sound_slots: preview.control_sound_slots, unsupported_control_sounds: preview.unsupported_control_sounds, - control_effect_random_states: HashMap::new(), + control_effect_instance_states: HashMap::new(), component_pose_buffers: preview.component_pose_buffers, component_mount_poses: preview.component_mount_poses, dynamic_component_geometry: preview.dynamic_component_geometry, @@ -9488,6 +9542,7 @@ impl StaticVulkanApp { dynamic_shadow_casters: preview.dynamic_shadow_casters, sun_occlusion_range_indices: preview.sun_occlusion_range_indices, terrain: preview.terrain, + fixed_water_clock_ms: preview.fixed_water_clock_ms, world_range_indices, world_range_index_counts, camera: preview.camera, @@ -9547,17 +9602,7 @@ impl StaticVulkanApp { horizontal_fov(&self.view_camera, aspect), )?; } - let approximate_portals = self - .world_range_indices - .iter() - .filter(|&&index| { - let flags = self.mesh.draw_ranges[index].batch_flags; - flags & 8 != 0 - && flags & 0x50 != 0x40 - && !self.type16_present_ranges.contains(&index) - }) - .count(); - eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)"); + eprintln!("render_coverage msh_type16_colors_unsupported={} (authored vertex colors are not decoded; using white)", self.type16_present_ranges.len()); let randomized_lights = self .control_point_light_slots .iter() @@ -9976,20 +10021,18 @@ impl StaticVulkanApp { .flatten(); } } - let control_effect_random_samples = advance_control_effect_random_states( - &mut self.control_effect_random_states, + let control_effect_random_samples = advance_control_effect_instances( + &mut self.control_effect_instance_states, &self.ambient_effect_slots, &self.control_point_light_slots, &self.control_sound_slots, clock.id, control_clock_ms, - effect_elapsed_seconds, ); let control_point_lights = update_control_point_lights( &mut self.control_point_light_slots, &self.component_pose_buffers, &self.component_mount_poses, - effect_elapsed_seconds, &control_effect_random_samples, std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_some() && !self.control_point_lights_diagnostic_reported @@ -9997,13 +10040,12 @@ impl StaticVulkanApp { .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 control_vulkan_lights = active_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, )?; @@ -10046,13 +10088,7 @@ impl StaticVulkanApp { .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| { + let phase = random_sample.phase.map(|phase| { native_command_phase(slot.header_flags, phase, random_sample.phase_jitter) }); let evaluated = phase.and_then(|phase| { @@ -10061,7 +10097,7 @@ impl StaticVulkanApp { slot.command_index, &slot.world_from_effect, phase, - effect_elapsed_seconds, + random_sample.elapsed_seconds, self.view_camera.position, ) }); @@ -10192,13 +10228,9 @@ impl StaticVulkanApp { ) else { continue; }; - let Some(phase) = ambient_effect_phase( - slot.time_mode, - slot.header_flags, - effect_elapsed_seconds, - slot.duration_seconds, - ) - .map(|phase| native_command_phase(slot.header_flags, phase, random.phase_jitter)) else { + let Some(phase) = random.phase.map(|phase| { + native_command_phase(slot.header_flags, phase, random.phase_jitter) + }) else { continue; }; if let Some(event) = sound.command.update( @@ -10324,6 +10356,21 @@ impl StaticVulkanApp { &mut self.material_animations, renderer, )?; + let water_clock_ms = self + .fixed_water_clock_ms + .map_or(frame.clock.time_ms, |captured| { + if self.target_frames == 0 { + captured.wrapping_add(frame.clock.time_ms) + } else { + captured + } + }); + let mut uniforms = renderer.frame_uniforms(); + uniforms.camera_position = frame.camera.position; + uniforms.embm_phase = fparkan_render::water::legacy_embm_phase(water_clock_ms); + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update water clock uniforms: {error}"))?; for update in &frame.effect_updates { renderer .set_draw_range_index_count(update.range_index, update.index_count) @@ -10407,6 +10454,7 @@ impl StaticVulkanApp { .iter() .find(|light| light.active) .map(|light| VulkanPointLight { + owner_scope: 0, position: light.position, rgb: [light.color[0], light.color[1], light.color[2]], range: light.range, @@ -10422,6 +10470,7 @@ impl StaticVulkanApp { .iter() .filter(|light| light.active) .map(|light| VulkanPointLight { + owner_scope: 0, position: light.position, rgb: [light.color[0], light.color[1], light.color[2]], range: light.range, @@ -11201,6 +11250,7 @@ mod tests { positions: &[[f32; 3]], ) -> (VulkanStaticMesh, VulkanStaticDrawRange) { let range = VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: u32::try_from(positions.len()).expect("fixture length fits u32"), material_index: 0, @@ -11208,6 +11258,7 @@ mod tests { batch_flags, pipeline_state: LegacyPipelineState::default(), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, }; let mesh = VulkanStaticMesh { @@ -12457,6 +12508,7 @@ mod tests { .map(|index| index + environment_vertex_start), ); let draw_range = |first_index, index_count| VulkanStaticDrawRange { + owner_scope: 0, first_index, index_count, material_index: 0, @@ -12467,6 +12519,7 @@ mod tests { LegacyDepthMode::TestReadOnly, ), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }; let mut mesh = VulkanStaticMesh { @@ -13091,6 +13144,35 @@ mod tests { #[test] #[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"] + fn gog_static_preview_accepts_terrain_without_placed_objects() { + let root = std::env::var_os("FPARKAN_GAME_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + let path = "DATA/MAPS/41/Land.msh"; + let bytes = std::fs::read(root.join(path)).expect("original map mesh"); + let document = fparkan_assets::decode_nres_payload(Arc::from(bytes)).unwrap(); + let land = fparkan_terrain_format::decode_land_msh(&document).unwrap(); + let terrain = TerrainWorld::from_land_msh(&land).unwrap(); + let preview = static_preview_mesh_and_materials( + &MissionAssets::default(), + &terrain, + &[], + None, + None, + None, + &root, + path, + ) + .expect("terrain is a complete scene without object meshes"); + assert_eq!(preview.mesh_components, 0); + assert_eq!(preview.terrain_components, 1); + assert!(!preview.mesh.vertices.is_empty()); + assert!(!preview.mesh.indices.is_empty()); + assert!(!preview.materials.is_empty()); + } + + #[test] + #[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"] fn gog_terrain_detail_uses_multitexture_animation_block() { let root = std::env::var_os("FPARKAN_GAME_ROOT") .map(PathBuf::from) @@ -13139,6 +13221,7 @@ mod tests { #[test] fn draw_range_reorder_remaps_all_material_animation_bindings() { let range = |first_index, material_index| VulkanStaticDrawRange { + owner_scope: 0, first_index, index_count: 3, material_index, @@ -13149,6 +13232,7 @@ mod tests { LegacyDepthMode::TestReadOnly, ), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }; let mut mesh = VulkanStaticMesh { @@ -13203,6 +13287,7 @@ mod tests { #[test] fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() { let range = |blend, batch_flags| VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, material_index: 0, @@ -13210,6 +13295,7 @@ mod tests { batch_flags, pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly), terrain_base_only: false, + terrain_reflection: false, alpha_test_reference: 0, }; @@ -13252,7 +13338,8 @@ mod tests { let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04) .unwrap() .unwrap(); - assert!((alpha - 0.5).abs() < 0.000_001); + // Native RSQRTSS perturbs the distance before the extra square root. + assert!((alpha - 0.5).abs() < 0.000_3); assert_eq!( native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04), Ok(Some(1.0)) @@ -13564,6 +13651,7 @@ mod tests { row_index: 0, }; let control_slot = ControlEffectSlot { + light_owner_scope: 1, time_mode: 1, header_flags: 9, random_instance_key: key, @@ -13651,6 +13739,7 @@ mod tests { }; let view_camera = free_camera.preview_frame(16.0 / 9.0); let preview = StaticPreviewScene { + fixed_water_clock_ms: None, terrain_lod: None, dynamic_vertex_start: 3, mesh: VulkanStaticMesh { @@ -13667,12 +13756,14 @@ mod tests { .collect(), indices: vec![0, 1, 2], draw_ranges: vec![VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 3, material_index: 0, lightmap_index: u8::MAX, batch_flags: 0, terrain_base_only: false, + terrain_reflection: false, pipeline_state: world_pipeline_state( LegacyBlendMode::Opaque, LegacyDepthMode::TestReadOnly, @@ -13733,11 +13824,11 @@ mod tests { } fn control_random_snapshot( - state: &ControlEffectRandomState, + state: &ControlEffectInstanceState, ) -> ( u32, u32, - ControlEffectRandomSample, + ControlEffectFrameSample, Option<SceneSampleId>, Option<u32>, ) { @@ -13809,7 +13900,7 @@ mod tests { .sky_mesh .vertices .is_empty()); - let first_random = control_random_snapshot(&app.control_effect_random_states[&key]); + let first_random = control_random_snapshot(&app.control_effect_instance_states[&key]); assert_eq!(first_random.3, Some(SceneSampleId::Live(0))); assert_ne!(first_random.0, 0); assert_ne!(first_random.1, 0); @@ -13822,7 +13913,7 @@ mod tests { .expect("renderer-free redraw does not sample simulation state"); } assert_eq!( - control_random_snapshot(&app.control_effect_random_states[&key]), + control_random_snapshot(&app.control_effect_instance_states[&key]), first_random ); assert_eq!( @@ -13844,7 +13935,7 @@ mod tests { SceneSampleId::Live(1) ); assert_ne!( - control_random_snapshot(&app.control_effect_random_states[&key]), + control_random_snapshot(&app.control_effect_instance_states[&key]), first_random ); assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time); @@ -13941,7 +14032,7 @@ mod tests { .as_mut() .unwrap() .environment_frame = first_environment_frame.clone(); - let first_random = control_random_snapshot(&app.control_effect_random_states[&key]); + let first_random = control_random_snapshot(&app.control_effect_instance_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(); @@ -14046,7 +14137,7 @@ mod tests { } assert!(reprojected_counts.iter().all(|count| *count >= 17)); assert_eq!( - control_random_snapshot(&app.control_effect_random_states[&key]), + control_random_snapshot(&app.control_effect_instance_states[&key]), first_random ); assert_eq!( @@ -14104,7 +14195,7 @@ mod tests { .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_random = control_random_snapshot(&app.control_effect_instance_states[&key]); let first_vertices = app.mesh.vertices.clone(); assert_eq!(first_material_phase, 0); assert_eq!(first_random.3, Some(SceneSampleId::Replay(0))); @@ -14145,7 +14236,7 @@ mod tests { 0 ); assert_eq!( - control_random_snapshot(&app.control_effect_random_states[&key]), + control_random_snapshot(&app.control_effect_instance_states[&key]), first_random ); assert_eq!( @@ -14179,7 +14270,7 @@ mod tests { (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]); + let next_random = control_random_snapshot(&app.control_effect_instance_states[&key]); assert_eq!(next_random.3, Some(SceneSampleId::Replay(1))); assert_eq!(next_random.4, Some(500)); assert_ne!(next_random, first_random); @@ -14215,7 +14306,7 @@ mod tests { next_environment_frame ); assert_eq!( - control_random_snapshot(&app.control_effect_random_states[&key]), + control_random_snapshot(&app.control_effect_instance_states[&key]), next_random ); assert_eq!( @@ -15255,7 +15346,97 @@ mod tests { 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); + assert!((distance - 3.0).abs() < 1.0e-3); + } + + #[test] + #[ignore = "requires the original GOG installation and native SSE reference"] + fn gog_portal_alpha_keeps_the_reference_vertex_outside_the_viewport() { + 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_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(usize::MAX).unwrap(), + ) + .expect("original AutoDemo"); + let assets = loaded_mission_assets(&engine).expect("mission assets"); + let root = &loaded_mission_object_drafts(&engine).expect("mission objects")[4]; + let visual = assets + .visual_by_id(assets.object_visuals[4][0]) + .expect("factory visual"); + let model = assets.model_by_id(visual.model_id.unwrap()).unwrap(); + let mesh = project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( + &model.validated, + LegacyIron3dEulerTransform { + translation: root.position, + orientation_radians: root.orientation_raw, + }, + root.scale, + 0, + ) + .expect("factory mesh"); + // GOG capture: yaw/roll expose only part of the window; alpha was read + // after Terrain's callback stored it, before the renderer consumed it. + let transform = RawCameraTransform { + words: [ + 3_204_541_090, + 3_209_392_467, + 1_051_461_443, + 1_135_728_591, + 1_063_051_501, + 3_203_294_891, + 1_045_010_100, + 1_143_538_729, + 0, + 1_053_254_103, + 1_064_028_839, + 1_093_589_271, + 0, + 0, + 0, + 1_065_353_216, + ], + }; + let camera = VulkanStaticCamera::from_legacy_d3d7( + transform, + LegacyD3d7Projection { + viewport: [0, 0, 800, 600], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.04, + }, + ) + .expect("native edge camera"); + let range = mesh.draw_ranges[33]; + assert_eq!(range.batch_flags, 0x0108); + let clip_vertex = |offset: usize| -> [f32; 4] { + let index = mesh.indices[range.first_index as usize + offset] as usize; + let [x, y, z] = mesh.vertices[index].position; + let point = [x, y, z, 1.0]; + std::array::from_fn(|column| { + (0..4) + .map(|row| point[row] * camera.clip_from_world[row * 4 + column]) + .sum() + }) + }; + let outside = clip_vertex(0); + let inside = clip_vertex(1); + assert!(outside[0] > outside[3]); + assert!(inside[0].abs() < inside[3]); + let distance = first_indexed_vertex_distance(&mesh, 33, transform.translation()) + .expect("portal reference distance"); + assert!((distance - f32::from_bits(0x42D5_7EE4)).abs() < 2.0e-5); + let alpha = + native_msh_diffuse_alpha_override(&mesh, 33, false, transform.translation(), 1.04) + .expect("portal alpha") + .expect("portal override"); + assert!((alpha - f32::from_bits(0x3F05_190C)).abs() < 1.0e-6); } #[test] @@ -15716,13 +15897,18 @@ mod tests { let mut value: serde_json::Value = serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json")) .unwrap(); - value["sky_uv_clock"] = - serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32}); + value["sky_uv_clock"] = serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32, "raw_clock": 272342176_u32}); let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap(); - assert_eq!( - capture.sky_uv_clock.unwrap().elapsed_milliseconds, - 4051271258 - ); + let clock = capture.sky_uv_clock.unwrap(); + assert_eq!(clock.elapsed_milliseconds, 4051271258); + assert_eq!(clock.raw_clock, Some(272342176)); + value["sky_uv_clock"] + .as_object_mut() + .unwrap() + .remove("raw_clock"); + let old_capture = + parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(old_capture.sky_uv_clock.unwrap().raw_clock, None); value["sky_uv_clock"]["verified"] = serde_json::json!(false); assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err()); } @@ -16405,7 +16591,7 @@ mod tests { 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); + assert!((actual - expected).abs() < expected * 0.000_3 + 1e-5); } for selector in [1, 2] { @@ -16434,7 +16620,7 @@ mod tests { 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(3, 0, 0.5, 2.0), Some(0.75)); 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)); @@ -16523,6 +16709,7 @@ mod tests { #[test] fn op1_control_lights_use_native_phase_owner_transform_and_flags() { let mut slot = ControlEffectSlot { + light_owner_scope: 1, time_mode: 1, header_flags: 0, random_instance_key: ControlEffectInstanceKey { @@ -16559,21 +16746,35 @@ mod tests { let mounts = vec![vec![None]]; let inactive = slot.clone(); - let random_samples = HashMap::new(); + let mut instance_states = HashMap::new(); + let random_samples = advance_control_effect_instances( + &mut instance_states, + &[], + std::slice::from_ref(&slot), + &[], + SceneSampleId::Replay(1), + 250, + ); assert!(update_control_point_lights( &mut [inactive], &poses, &mounts, - 0.25, &random_samples, false, ) .is_empty()); + let random_samples = advance_control_effect_instances( + &mut instance_states, + &[], + std::slice::from_ref(&slot), + &[], + SceneSampleId::Replay(2), + 750, + ); let lights = update_control_point_lights( std::slice::from_mut(&mut slot), &poses, &mounts, - 0.75, &random_samples, false, ); @@ -16583,6 +16784,10 @@ mod tests { 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!( + light.owner_scope, 1, + "native local light reaches its owning root" + ); assert_eq!(flags, 0xA000_0000); assert_eq!( [ @@ -16599,67 +16804,90 @@ mod tests { 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( + let first = advance_control_effect_instances( &mut states, &[], &sibling_slots, &[], SceneSampleId::Replay(7), - 150, - 1.5, + 1500, ); 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( + let mut expected_state = ControlEffectInstanceState::new( slot.random_seed, slot.header_flags, slot.phase_jitter, slot.random_shift, ); + let expected = expected_state.advance(SceneSampleId::Replay(7), 1500, -1.0); + assert_eq!(first_sample.phase_jitter, expected.phase_jitter); + assert_eq!(first_sample.accumulated_shift, expected.accumulated_shift); + assert_eq!(first_sample.phase, Some(-1.0)); + assert!((first_sample.elapsed_seconds - 1.5).abs() < 1e-6); assert_eq!( - first_sample, - expected_state.advance(SceneSampleId::Replay(7), 150, -1.0) - ); - assert_eq!( - advance_control_effect_random_states( + advance_control_effect_instances( &mut states, &[], &sibling_slots, &[], SceneSampleId::Replay(7), - 150, - 1.5, + 1500, )[&key], first_sample, "a repeated read of the same captured update reuses the shared sample" ); - let second = advance_control_effect_random_states( + let second = advance_control_effect_instances( &mut states, &[], &sibling_slots, &[], SceneSampleId::Replay(8), - 200, - 0.5, - ); - assert_eq!( - second[&key], - expected_state.advance(SceneSampleId::Replay(8), 200, 1.0) + 500, ); + let expected = expected_state.advance(SceneSampleId::Replay(8), 500, 1.0); + assert_eq!(second[&key].phase_jitter, expected.phase_jitter); + assert_eq!(second[&key].accumulated_shift, expected.accumulated_shift); + assert_eq!(second[&key].phase, Some(1.0)); + assert_eq!(second[&key].elapsed_seconds, 0.5); 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)); + assert_eq!(states[&key].last_update_clock_ms, Some(500)); + + let clock = states.get_mut(&key).unwrap().clock.as_mut().unwrap(); + clock.set_time(4000.0, 1).unwrap(); + clock.request_restart(); + let later = advance_control_effect_instances( + &mut states, + &[], + &sibling_slots, + &[], + SceneSampleId::Replay(9), + 4500, + ); + assert_eq!(later[&key].phase, Some(1.0)); + assert_eq!(later[&key].elapsed_seconds, 0.5); + let restarted = advance_control_effect_instances( + &mut states, + &[], + &sibling_slots, + &[], + SceneSampleId::Replay(10), + 6500, + ); + assert_eq!(restarted[&key].phase, Some(0.0)); + assert_eq!(restarted[&key].elapsed_seconds, 0.0); 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( + let mut negative_state = ControlEffectInstanceState::new( slot.random_seed, 0x229, slot.phase_jitter, @@ -16680,8 +16908,9 @@ mod tests { } #[test] - fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() { - let light = |position, active| VulkanPointLight { + fn op1_diffuse_list_retains_owner_lights_and_clears_inactive_lights() { + let light = |position, owner_scope, active| VulkanPointLight { + owner_scope, position, rgb: [0.25, 0.5, 0.75], range: 15.0, @@ -16689,15 +16918,21 @@ mod tests { 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), + (light([1.0, 2.0, 3.0], 5, true), 0xA000_0000), + (light([4.0, 5.0, 6.0], 0, true), 0x2000_0000), + (light([7.0, 8.0, 9.0], 2, true), 0x8000_0000), + (light([10.0, 11.0, 12.0], 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]); + let active = active_control_point_lights(&raw_records); + assert_eq!(active.len(), 3); + assert_eq!( + active + .iter() + .map(|light| light.owner_scope) + .collect::<Vec<_>>(), + [5, 0, 2] + ); assert_eq!(raw_records.len(), 4); assert_eq!( raw_records @@ -16707,9 +16942,9 @@ mod tests { vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0] ); - assert!(ordinary_control_point_lights(&[]).is_empty()); + assert!(active_control_point_lights(&[]).is_empty()); assert!( - ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)]) + active_control_point_lights(&[(light([13.0, 14.0, 15.0], 0, false), 0x2000_0000)]) .is_empty() ); } diff --git a/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs index 599178c..1dd707d 100644 --- a/apps/fparkan-game/src/terrain_lod.rs +++ b/apps/fparkan-game/src/terrain_lod.rs @@ -233,12 +233,14 @@ mod tests { #[test] fn cell_ranges_keep_material_state_and_separate_generated_bands() { let range = VulkanStaticDrawRange { + owner_scope: 0, first_index: 0, index_count: 12, material_index: 7, lightmap_index: u8::MAX, batch_flags: 0, terrain_base_only: true, + terrain_reflection: false, pipeline_state: super::super::world_pipeline_state( fparkan_render::LegacyBlendMode::Opaque, fparkan_render::LegacyDepthMode::TestWrite, |
