From 315afff2202f3d7cfd7299a29c0ca67a757b9e2e Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 1 Nov 2026 13:13:53 +0400 Subject: feat(fx): track phase and restart per effect instance --- README.md | 4 +- apps/fparkan-game/src/main.rs | 292 ++++++++++++++++---------------- crates/fparkan-fx/src/clock.rs | 190 +++++++++++++++++++++ crates/fparkan-fx/src/lib.rs | 1 + docs/appendices/knowledge-boundaries.md | 3 + docs/reference/environment-effects.md | 25 ++- docs/tomes/05-render.md | 16 +- 7 files changed, 379 insertions(+), 152 deletions(-) create mode 100644 crates/fparkan-fx/src/clock.rs diff --git a/README.md b/README.md index 4e16908..0b5d393 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые события. Это просмотр карты с полной графической сценой; прохождение миссии и игровое поведение юнитов пока не запускаются. Модели начинают с настроек узлов -прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим -часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной +прототипа. Поддерживаемые Control-анимации идут по часам сцены, а эффекты — по +часам своих экземпляров с общим начальным моментом. Неподдерживаемые Control-треки остаются на сохранённой начальной позе. Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры; под основаниями строений сохраняется подробная сетка. diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index dc996c9..0d4fd0c 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -759,9 +759,11 @@ struct ControlEffectInstanceKey { } #[derive(Clone, Copy, Debug, Default, PartialEq)] -struct ControlEffectRandomSample { +struct ControlEffectFrameSample { phase_jitter: f32, accumulated_shift: [f32; 3], + phase: Option, + elapsed_seconds: f32, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -799,18 +801,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, last_update_clock_ms: Option, + clock: Option, } -impl ControlEffectRandomState { +impl ControlEffectInstanceState { fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self { Self { header_flags, @@ -818,9 +821,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 +833,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. @@ -5310,8 +5314,7 @@ fn update_control_point_lights( slots: &mut [ControlEffectSlot], component_pose_buffers: &[Vec>], component_mount_poses: &[Vec>], - elapsed_seconds: f32, - random_samples: &HashMap, + random_samples: &HashMap, emit_diagnostic: bool, ) -> Vec<(VulkanPointLight, u32)> { let mut lights = Vec::new(); @@ -5323,10 +5326,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, @@ -5335,13 +5338,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; @@ -5542,6 +5541,7 @@ fn append_initial_control_effect_slots( let duration_seconds = header.duration_seconds; if !duration_seconds.is_finite() || 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()) @@ -5550,8 +5550,7 @@ fn append_initial_control_effect_slots( record_skip("header_values", document.commands().len(), false, None); continue; } - if !matches!(header.time_mode, 0 | 1 | 2) - || (header.time_mode != 0 && duration_seconds <= 0.0) + if !matches!(header.time_mode, 0..=3) || (header.time_mode != 0 && duration_seconds <= 0.0) { *unsupported_sounds += sound_commands; record_skip( @@ -6013,65 +6012,70 @@ fn control_effect_instance_seed( ) } -fn advance_control_effect_random_states( - states: &mut HashMap, +fn advance_control_effect_instances( + states: &mut HashMap, ambient_slots: &[AmbientEffectGpuSlot], point_light_slots: &[ControlEffectSlot], sound_slots: &[ControlSoundSlot], sample_id: SceneSampleId, control_clock_ms: u32, - elapsed_seconds: f32, -) -> HashMap { +) -> 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, + 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() @@ -6203,8 +6207,7 @@ fn update_ambient_effect_geometry( component_pose_buffers: &[Vec>], mesh: &mut VulkanStaticMesh, material_animations: &mut [MaterialAnimationBinding], - elapsed_seconds: f32, - random_samples: &HashMap, + random_samples: &HashMap, camera_world: [f32; 3], ) -> Result, String> { let mut updates = Vec::with_capacity(slots.len()); @@ -6212,7 +6215,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, @@ -6222,13 +6231,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; }; @@ -6949,42 +6954,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 { - 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]); @@ -9422,7 +9404,7 @@ struct StaticVulkanApp { control_point_light_slots: Vec, control_sound_slots: Vec, unsupported_control_sounds: usize, - control_effect_random_states: HashMap, + control_effect_instance_states: HashMap, component_pose_buffers: Vec>>, component_mount_poses: Vec>>, dynamic_component_geometry: Vec>>, @@ -9530,7 +9512,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, @@ -10018,20 +10000,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 @@ -10045,7 +10025,6 @@ impl StaticVulkanApp { &self.component_pose_buffers, &mut self.mesh, &mut self.material_animations, - effect_elapsed_seconds, &control_effect_random_samples, self.view_camera.position, )?; @@ -10088,13 +10067,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| { @@ -10103,7 +10076,7 @@ impl StaticVulkanApp { slot.command_index, &slot.world_from_effect, phase, - effect_elapsed_seconds, + random_sample.elapsed_seconds, self.view_camera.position, ) }); @@ -10234,13 +10207,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( @@ -13826,11 +13795,11 @@ mod tests { } fn control_random_snapshot( - state: &ControlEffectRandomState, + state: &ControlEffectInstanceState, ) -> ( u32, u32, - ControlEffectRandomSample, + ControlEffectFrameSample, Option, Option, ) { @@ -13902,7 +13871,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); @@ -13915,7 +13884,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!( @@ -13937,7 +13906,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); @@ -14034,7 +14003,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(); @@ -14139,7 +14108,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!( @@ -14197,7 +14166,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))); @@ -14238,7 +14207,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!( @@ -14272,7 +14241,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); @@ -14308,7 +14277,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!( @@ -16622,7 +16591,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)); @@ -16747,21 +16716,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, ); @@ -16787,67 +16770,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, diff --git a/crates/fparkan-fx/src/clock.rs b/crates/fparkan-fx/src/clock.rs new file mode 100644 index 0000000..9382b33 --- /dev/null +++ b/crates/fparkan-fx/src/clock.rs @@ -0,0 +1,190 @@ +//! Per-instance time for the owner-independent Effect.dll phase modes. + +/// The clock fields read by Effect.dll +0x5C60 and set by +0x7B00/+0x7B40. +#[derive(Clone, Debug)] +pub struct FxInstanceClock { + header_mode: u32, + mode: u32, + header_flags: u32, + duration_ms: f32, + origin_ms: f32, + end_ms: f32, + constant_phase: f32, + restart_requested: bool, +} + +impl FxInstanceClock { + /// Creates an instance at its own origin, using the document's default mode. + /// Modes requiring an owner remain unsupported by this clock. + pub fn new( + mode: u32, + header_flags: u32, + duration_seconds: f32, + origin_ms: f32, + ) -> Option { + let duration_ms = duration_seconds * 1000.0; + if mode > 3 + || !duration_ms.is_finite() + || duration_ms < 0.0 + || !origin_ms.is_finite() + || (mode != 0 && duration_ms == 0.0) + || !(origin_ms + duration_ms).is_finite() + { + return None; + } + Some(Self { + header_mode: mode, + mode, + header_flags, + duration_ms, + origin_ms, + end_ms: origin_ms + duration_ms, + constant_phase: 0.0, + restart_requested: false, + }) + } + + /// Changes the caller-selected mode and epoch without changing the document. + pub fn set_time(&mut self, now_ms: f32, mode: u32) -> Option<()> { + if mode > 3 + || !now_ms.is_finite() + || !(now_ms + self.duration_ms).is_finite() + || (mode != 0 && self.duration_ms == 0.0) + { + return None; + } + self.origin_ms = now_ms; + self.end_ms = now_ms + self.duration_ms; + self.mode = mode; + Some(()) + } + + /// Sets the value used by mode zero; command evaluation clamps it later. + pub fn set_constant_phase(&mut self, phase: f32) -> Option<()> { + if !phase.is_finite() { + return None; + } + self.constant_phase = phase; + Some(()) + } + + /// Requests the native deferred restart: the current lifetime finishes first. + pub fn request_restart(&mut self) { + self.restart_requested = true; + } + + /// Applies the restart portion of Effect.dll +0x8120 before phase sampling. + /// The new lifetime starts at the update time rather than retaining overshoot. + pub fn update(&mut self, now_ms: f32) -> Option<()> { + if !now_ms.is_finite() { + return None; + } + if self.restart_requested && self.end_ms <= now_ms { + self.set_time(now_ms, self.header_mode)?; + self.restart_requested = false; + } + Some(()) + } + + /// Elapsed instance time for command simulation, independent of app startup. + pub fn elapsed_seconds(&self, now_ms: f32) -> Option { + now_ms + .is_finite() + .then_some((now_ms - self.origin_ms) * 0.001) + } + + /// Samples the raw phase before command clamping and random phase jitter. + /// `manager_flag_a8` is the native owner bit tested by header gates 0x80/0x100. + pub fn phase(&self, now_ms: f32, manager_flag_a8: bool) -> Option { + if !now_ms.is_finite() { + return None; + } + if (self.header_flags & 0x80 != 0 && !manager_flag_a8) + || (self.header_flags & 0x100 != 0 && manager_flag_a8) + { + return Some(0.0); + } + let ratio = (now_ms - self.origin_ms) / (self.end_ms - self.origin_ms); + let mut phase = match self.mode { + 0 => self.constant_phase, + 1 => ratio, + 2 => ratio - ratio.floor(), + 3 => 1.0 - ratio, + _ => return None, + }; + if self.header_flags & 0x200 != 0 { + phase *= ratio; + } + if self.header_flags & 0x20 != 0 { + if phase >= 0.5 { + phase = 1.0 - phase; + } + phase *= 2.0; + } + phase.is_finite().then_some(phase) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_epoch_modes_and_unclamped_header_transforms() { + // Effect.dll GOG +0x5C60, x87 CW 007F; native oracle uses these epochs. + for (mode, expected) in [ + (0, [0.375, 0.375, 0.375, 0.375, 0.375]), + (1, [0.0, 0.25, 0.5, 1.0, 1.25]), + (2, [0.0, 0.25, 0.5, 0.0, 0.25]), + (3, [1.0, 0.75, 0.5, 0.0, -0.25]), + ] { + let mut clock = FxInstanceClock::new(mode, 0, 4.0, 135_000.0).unwrap(); + clock.set_constant_phase(0.375).unwrap(); + for (now, phase) in [135_000.0, 136_000.0, 137_000.0, 139_000.0, 140_000.0] + .into_iter() + .zip(expected) + { + assert_eq!(clock.phase(now, false), Some(phase)); + } + } + for (flags, expected) in [(0x200, 2.25), (0x20, -1.0), (0x220, -2.5)] { + let clock = FxInstanceClock::new(1, flags, 4.0, 135_000.0).unwrap(); + assert_eq!(clock.phase(141_000.0, false), Some(expected)); + } + } + + #[test] + fn caller_mode_and_deferred_restart_use_the_instance_origin() { + let mut clock = FxInstanceClock::new(2, 0, 4.0, 135_000.0).unwrap(); + clock.set_time(136_000.0, 3).unwrap(); + clock.request_restart(); + clock.update(139_999.0).unwrap(); + assert!(clock.phase(139_999.0, false).unwrap() < 0.001); + clock.update(140_250.0).unwrap(); + assert_eq!(clock.phase(140_250.0, false), Some(0.0)); + assert_eq!(clock.elapsed_seconds(140_750.0), Some(0.5)); + assert_eq!(clock.phase(141_250.0, false), Some(0.25)); + } + + #[test] + fn manager_header_gates_precede_phase_transforms() { + for (flags, manager, expected) in [ + (0x80, false, 0.0), + (0x80, true, 0.5), + (0x100, false, 0.5), + (0x100, true, 0.0), + ] { + let clock = FxInstanceClock::new(1, flags, 2.0, 20_000.0).unwrap(); + assert_eq!(clock.phase(21_000.0, manager), Some(expected)); + } + assert_eq!( + FxInstanceClock::new(0, 0, 0.0, 0.0) + .unwrap() + .phase(100.0, false), + Some(0.0) + ); + assert!(FxInstanceClock::new(1, 0, 0.0, 0.0).is_none()); + assert!(FxInstanceClock::new(0, 0, f32::MAX, 0.0).is_none()); + assert!(FxInstanceClock::new(4, 0, 2.0, 0.0).is_none()); + } +} diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 8a567de..4201ffc 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -9,6 +9,7 @@ pub mod ambient_op3; pub mod ambient_sound; pub mod atmosphere; +pub mod clock; pub mod environment; pub mod fx_visual; pub mod op7; diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 04dc822..baf5ed5 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -91,6 +91,9 @@ Control Op1 пока использует цвет и радиус без слу Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные time modes/привязки считаются как `render_coverage control_op2_unsupported`. Управление фазой экземпляра командами Control остаётся до его подключения в runtime. +Часы экземпляра поддерживают modes 0–3, смену режима и отложенный перезапуск; +preview создаёт их в момент ноль. Динамический manager A8 заменён нулём +(`control_fx_manager_flag_stub`); очередь update с шагом 50 ms ещё не подключена. При расширении выбранных начальных строк все пропущенные команды считаются в `control_fx_unsupported`, а time modes 4–17 — в `control_fx_requires_game_state`; причина и владелец выводятся в лог. Неразобранные FXID считаются отдельно в `control_fx_invalid_documents`. diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 677c159..0e709d9 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -416,10 +416,23 @@ RVA `0x93D0`, ветка mode 1 с `0x9739`, сборка столбцов `0x97 XY, ненулевой выбирает статические quad/cap координаты. Op4 вычисляет локальное расстояние камеры тем же component-wise projection, что использует native path. -FX-команды используют общий preview clock и ограниченный набор time modes: -mode 0 оставляет фазу равной нулю, mode 1 вычисляет `elapsed / duration` без -зажатия, mode 2 повторяет цикл. Header flags `0x200` и `0x20` преобразуют эту -фазу; command path `0x8120` зажимает результат только после преобразований. +FX-команды одной Control-строки используют общие часы экземпляра +`fparkan_fx::clock::FxInstanceClock`. Время хранится в миллисекундах `f32`, +отдельно от длительности документа: mode 0 возвращает постоянную фазу +(при создании ноль), mode 1 — `(now − origin) / duration`, mode 2 повторяет +цикл, mode 3 возвращает `1 − (now − origin) / duration`. +Header flags `0x200` и `0x20` преобразуют незажатую фазу; command path +`0x8120` зажимает результат только после преобразований. +Вызов `set_time` меняет начало отсчёта и режим экземпляра, сохраняя режим +документа. Запрос перезапуска (`Effect.dll` RVA `0x7B40`, instance bit `0x20`) +срабатывает после конца текущего времени жизни: следующий update начинает +новый цикл в своём времени и возвращает режим документа. Это отдельный флаг, +не header `0x20`, складывающий фазу треугольником. Правила режимов, gates +`0x80/0x100` и установления времени сверены вызовом эталонной DLL с x87 CW +`0x007F`; отложенный переход закреплён Rust-тестом по ветви update `0x8120`. +Все экземпляры статического preview пока создаются в момент ноль; геометрия, +свет и звук получают одну фазу на обновление сцены. Команды Control ещё не +меняют эти часы, а нативная очередь обновления с шагом 50 ms не моделируется. В [эталонной Effect.dll](original-binaries.md) RVA `0x5C60` режимы 4–17 читают состояние владельца или провайдера; режим 14 тоже входит в эту группу. Пропуски при расширении выбранных начальных Control-строк учитываются по @@ -433,8 +446,8 @@ Effect renderer проверяет header bit `0x800` только при render selector 0; native AniMesh component path был захвачен с selector 1, поэтому preview использует selector 1 и не подавляет такие visual commands. Этот capture также показал `0xD00` FX entries в 53-entry manager. Динамическое -состояние manager `A8`, связанное с header gate и фактической FX phase, пока не -моделируется. Header bit `0x10` относится к activation lifecycle и сам по себе +состояние manager `A8` пока заменено нулём; экземпляры с gates `0x80/0x100` +считаются в `control_fx_manager_flag_stub`. Header bit `0x10` относится к activation lifecycle и сам по себе не отключает draw. Header jitter и random shift хранятся один раз на Control row и совместно применяются к его sibling-командам; отдельный детерминированный поток каждой diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 266cb47..7cbe7d7 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -740,6 +740,19 @@ struct FxHeader60 { локальную точку опоры, scale -- базовый масштаб экземпляра. Unknown flags и settings ID сохраняются. +У каждого экземпляра эффекта есть своё начало отсчёта. Например, эффект +длительностью четыре секунды, созданный на 135-й секунде, на 137-й находится +в середине цикла: `(137 − 135) / 4 = 0,5`. Режим может идти вперёд, +повторяться, идти назад или сохранять постоянную фазу. Вызывающий код может +сменить режим экземпляра, не меняя документ. Запрос перезапуска ждёт конца +текущего цикла и начинает новый при следующем обновлении. + +Эти правила реализованы в `fparkan-fx::clock`. Приложение передаёт одну фазу +геометрии, свету и звуку одной Control-строки; повторная отправка кадра не +пересчитывает её и не расходует случайные числа повторно. В статической сцене +все экземпляры пока рождаются в момент ноль. Управляющие команды Control и +очередь обновления с шагом 50 миллисекунд остаются задачами игрового цикла. + Каждая команда начинается с `uint32_t command_word`: ```text @@ -867,7 +880,8 @@ Effect renderer подавляет header bit `0x800` только при render selector 0. Native AniMesh component path захвачен с selector 1, а в его 53-entry manager присутствуют FX entries с header `0xD00`; preview использует selector 1 и пропускает такие visual commands. Изменения manager -`A8`, которые задают runtime phase/header gate, пока не моделируются. Header +`A8`, которые задают runtime phase/header gate, пока заменены нулём с явным +счётчиком `control_fx_manager_flag_stub`. Header bit `0x10` относится к activation lifecycle и не выключает draw. Для visual effect renderer использует MAT0, указанный самой командой. Его -- cgit v1.2.3