diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-31 21:06:49 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-31 21:06:49 +0300 |
| commit | 7ba8a0d84e9d543e54c52a2519916bf95c9948e6 (patch) | |
| tree | 73cceea5a5961ebbe98a1791ab4fe22a9939e28d | |
| parent | 3f4059b72665061da72175faa2ea82db7ddf2c13 (diff) | |
| download | fparkan-7ba8a0d84e9d543e54c52a2519916bf95c9948e6.tar.xz fparkan-7ba8a0d84e9d543e54c52a2519916bf95c9948e6.zip | |
feat(fx): play ambient Control sound commands
| -rw-r--r-- | apps/fparkan-game/src/audio.rs | 120 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 138 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/ambient_sound.rs | 288 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 4 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 35 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 11 |
7 files changed, 573 insertions, 24 deletions
diff --git a/apps/fparkan-game/src/audio.rs b/apps/fparkan-game/src/audio.rs index 2de6464..a89e275 100644 --- a/apps/fparkan-game/src/audio.rs +++ b/apps/fparkan-game/src/audio.rs @@ -629,6 +629,7 @@ pub(crate) struct GameAudio { music: Player, variations: Player, weather_loop: NativeSpatialPlayer, + control_sounds: BTreeMap<usize, NativeSpatialPlayer>, one_shots: Vec<SpatialOneShot>, library: AudioLibrary, ambience: AmbienceScheduler, @@ -672,6 +673,7 @@ impl GameAudio { music, variations, weather_loop, + control_sounds: BTreeMap::new(), one_shots: Vec::new(), library, ambience: AmbienceScheduler::new(random_seed(), random_seed()), @@ -705,6 +707,11 @@ impl GameAudio { self.weather_loop .set_emitter_position(self.weather_position); self.retain_active_one_shots(); + self.control_sounds.retain(|_, player| !player.empty()); + for player in self.control_sounds.values() { + player.set_left_ear_position(left); + player.set_right_ear_position(right); + } for one_shot in &self.one_shots { one_shot.player.set_left_ear_position(left); one_shot.player.set_right_ear_position(right); @@ -825,19 +832,74 @@ impl GameAudio { SoundAction::OneShot => { self.retain_active_one_shots(); let asset = self.dynamic_sound(&event.archive, &event.name)?; - self.one_shots.push(self.new_spatial_one_shot( + self.one_shots.push(self.new_spatial_voice( &asset, event.position, volume, min_distance, max_distance, frequency_ratio, + false, )?); } } Ok(()) } + /// Each Control command owns its source; it must not replace the rain + /// loop or another instance using the same sample. + pub(crate) fn handle_control_sound( + &mut self, + id: usize, + event: &SoundEvent, + ) -> Result<(), AudioError> { + validate_point(event.position, "Control emitter")?; + let frequency_ratio = if event.frequency_ratio.is_finite() && event.frequency_ratio > 0.0 { + event.frequency_ratio + } else { + 1.0 + }; + match event.action { + SoundAction::StartLoop | SoundAction::OneShot => { + let asset = self.dynamic_sound(&event.archive, &event.name)?; + let voice = self.new_spatial_voice( + &asset, + event.position, + event.volume, + event.min_distance, + event.max_distance, + frequency_ratio, + event.action == SoundAction::StartLoop, + )?; + if let Some(previous) = self.control_sounds.insert(id, voice.player) { + previous.stop(); + } + eprintln!("control_sound_start id={id} resource={}:{} loop={} position={:?} range={}/{} frequency={}", event.archive, event.name, event.action == SoundAction::StartLoop, event.position, event.min_distance, event.max_distance, event.frequency_ratio); + } + SoundAction::SetLoopVolume => { + if let Some(player) = self.control_sounds.get(&id) { + player.set_emitter_position(event.position); + player.set_sound_parameters(event.min_distance, event.max_distance); + player.set_volume(event.volume); + player.set_speed(frequency_ratio); + } + } + SoundAction::StopLoop => { + if let Some(player) = self.control_sounds.remove(&id) { + player.stop(); + eprintln!("control_sound_stop id={id}"); + } + } + } + Ok(()) + } + + pub(crate) fn control_sound_is_playing(&self, id: usize) -> bool { + self.control_sounds + .get(&id) + .is_some_and(|player| !player.empty()) + } + /// Selects one of the original MUSIC/Track02..10.ogg files and loops it. pub(crate) fn play_music_track(&mut self, track: u8) -> Result<(), AudioError> { let bytes = self.library.music_bytes(track)?; @@ -875,6 +937,13 @@ impl GameAudio { one_shot.player.play(); } } + for player in self.control_sounds.values() { + if paused { + player.pause(); + } else { + player.play(); + } + } } /// Focus callback used by the window event loop. @@ -888,6 +957,9 @@ impl GameAudio { self.music.stop(); self.variations.stop(); self.weather_loop.stop(); + for player in self.control_sounds.values() { + player.stop(); + } for one_shot in &self.one_shots { one_shot.player.stop(); } @@ -904,7 +976,7 @@ impl GameAudio { Ok(()) } - fn new_spatial_one_shot( + fn new_spatial_voice( &self, asset: &SoundAsset, emitter: [f32; 3], @@ -912,6 +984,7 @@ impl GameAudio { min_distance: f32, max_distance: f32, frequency_ratio: f32, + looping: bool, ) -> Result<SpatialOneShot, AudioError> { validate_point(emitter, "emitter")?; let (left, right) = ear_positions(self.listener)?; @@ -919,7 +992,12 @@ impl GameAudio { player.set_sound_parameters(min_distance, max_distance); player.set_volume(volume); player.set_speed(frequency_ratio); - player.append(decoder_for(asset)?); + let source = decoder_for(asset)?; + if looping { + player.append(source.repeat_infinite()); + } else { + player.append(source); + } if self.paused { player.pause(); } else { @@ -1695,6 +1773,42 @@ mod tests { }) .expect("update original rain loop volume"); assert_eq!(audio.weather_loop.volume(), 0.5); + let mut machine = SoundEvent { + archive: "sounds.lib".to_string(), + name: "f_gener.wav".to_string(), + position: [1.0, 2.0, 3.0], + volume: 0.0, + min_distance: 15.0, + max_distance: 100.0, + frequency_ratio: 1.0, + action: SoundAction::StartLoop, + }; + audio + .handle_control_sound(0, &machine) + .expect("first machine"); + audio + .handle_control_sound(1, &machine) + .expect("second machine"); + assert!(audio.control_sound_is_playing(0)); + assert!(audio.control_sound_is_playing(1)); + assert!(!audio.weather_loop.empty()); + audio.set_paused(true); + assert!(audio + .control_sounds + .values() + .all(|player| player.is_paused())); + audio.set_paused(false); + assert!(audio + .control_sounds + .values() + .all(|player| !player.is_paused())); + machine.action = SoundAction::StopLoop; + audio + .handle_control_sound(0, &machine) + .expect("stop one machine"); + assert!(!audio.control_sound_is_playing(0)); + assert!(audio.control_sound_is_playing(1)); + assert!(!audio.weather_loop.empty()); audio .update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]) .expect("move listener"); diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 279fa2c..6101c94 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -18,6 +18,7 @@ use fparkan_assets::{ use fparkan_fx::ambient_op3::{ evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command, }; +use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState}; use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere}; use fparkan_fx::environment::{ decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera, @@ -707,7 +708,7 @@ struct AmbientEffectMeshPrimitive { } #[derive(Clone, Debug)] -struct ControlPointLightSlot { +struct ControlEffectSlot { time_mode: u32, header_flags: u32, random_instance_key: ControlEffectInstanceKey, @@ -728,6 +729,13 @@ struct ControlPointLightSlot { command: FxCommand, } +#[derive(Clone, Debug)] +struct ControlSoundSlot { + effect: ControlEffectSlot, + command: Op2SoundCommand, + state: Op2SoundState, +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] struct ControlEffectInstanceKey { object_index: usize, @@ -4617,7 +4625,9 @@ struct StaticPreviewScene { materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, ambient_effect_slots: Vec<AmbientEffectGpuSlot>, - control_point_light_slots: Vec<ControlPointLightSlot>, + control_point_light_slots: Vec<ControlEffectSlot>, + control_sound_slots: Vec<ControlSoundSlot>, + unsupported_control_sounds: usize, component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>, component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>, @@ -5253,8 +5263,8 @@ fn ambient_effect_world_from_component_pose_with_random_shift( ) } -fn control_point_light_world_from_component_pose_with_random_shift( - slot: &ControlPointLightSlot, +fn control_effect_world_from_component_pose_with_random_shift( + slot: &ControlEffectSlot, component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], random_shift: [f32; 3], ) -> Option<[[f32; 4]; 4]> { @@ -5278,7 +5288,7 @@ fn control_point_light_world_from_component_pose_with_random_shift( } fn update_control_point_lights( - slots: &mut [ControlPointLightSlot], + slots: &mut [ControlEffectSlot], component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], component_mount_poses: &[Vec<Option<VulkanNodePose>>], elapsed_seconds: f32, @@ -5298,13 +5308,11 @@ fn update_control_point_lights( .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 { + let Some(world_from_effect) = control_effect_world_from_component_pose_with_random_shift( + slot, + component_pose_buffers, + random_sample.accumulated_shift, + ) else { continue; }; slot.world_from_effect = world_from_effect; @@ -5461,7 +5469,9 @@ fn append_initial_control_effect_slots( effect_documents: &mut HashMap<String, FxDocument>, effect_materials: &mut HashMap<String, PreparedEffectMaterial>, slots: &mut Vec<AmbientEffectGpuSlot>, - point_light_slots: &mut Vec<ControlPointLightSlot>, + point_light_slots: &mut Vec<ControlEffectSlot>, + sound_slots: &mut Vec<ControlSoundSlot>, + unsupported_sounds: &mut usize, ) -> Result<(), String> { let Some(point_list) = control.point_list.as_ref() else { return Ok(()); @@ -5491,6 +5501,11 @@ fn append_initial_control_effect_slots( .get(&key) .ok_or_else(|| format!("Control FX {key} was not cached"))?; let header = document.header(); + let sound_commands = document + .commands() + .iter() + .filter(|command| command.opcode == FxOpcode::Op2) + .count(); let duration_seconds = header.duration_seconds; if !duration_seconds.is_finite() || duration_seconds < 0.0 @@ -5504,6 +5519,7 @@ fn append_initial_control_effect_slots( // scale conventions are outside this ambient subset. Leave them // unexpanded until their lifetime and transform contract are // understood rather than failing the whole mission preview. + *unsupported_sounds += sound_commands; continue; } let owner_node_index = @@ -5515,6 +5531,7 @@ fn append_initial_control_effect_slots( || (*owner_node == 0 && component_pose_buffer.is_none()) }); let Some(owner_node_index) = owner_node_index else { + *unsupported_sounds += sound_commands; continue; }; let owner_node_pose = control_effect_owner_node_pose( @@ -5523,6 +5540,7 @@ fn append_initial_control_effect_slots( owner_node_index, ); let Some(owner_node_pose) = owner_node_pose else { + *unsupported_sounds += sound_commands; continue; }; let root_transform = LegacyIron3dEulerTransform { @@ -5559,8 +5577,8 @@ fn append_initial_control_effect_slots( ); for (command_index, command) in document.commands().iter().enumerate() { - if ambient_effect_op1_supported(command) { - point_light_slots.push(ControlPointLightSlot { + if ambient_effect_op1_supported(command) || command.opcode == FxOpcode::Op2 { + let slot = ControlEffectSlot { time_mode: header.time_mode, header_flags: header.flags, random_instance_key, @@ -5579,7 +5597,21 @@ fn append_initial_control_effect_slots( world_from_effect, command_index, command: command.clone(), - }); + }; + if command.opcode == FxOpcode::Op2 { + if let Some(sound) = Op2SoundCommand::from_command(command) { + sound_slots.push(ControlSoundSlot { + effect: slot, + command: sound, + state: Op2SoundState::default(), + }); + } else { + *unsupported_sounds += 1; + eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)"); + } + } else { + point_light_slots.push(slot); + } continue; } let Some(kind) = ambient_effect_command_kind(command) else { @@ -5935,7 +5967,8 @@ fn control_effect_instance_seed( fn advance_control_effect_random_states( states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>, ambient_slots: &[AmbientEffectGpuSlot], - point_light_slots: &[ControlPointLightSlot], + point_light_slots: &[ControlEffectSlot], + sound_slots: &[ControlSoundSlot], sample_id: SceneSampleId, control_clock_ms: u32, elapsed_seconds: f32, @@ -5958,7 +5991,10 @@ fn advance_control_effect_random_states( initial_phase, )); } - for slot in point_light_slots { + 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, @@ -7928,6 +7964,8 @@ fn static_preview_mesh_and_materials( let mut material_animations = Vec::new(); let mut ambient_effect_slots = Vec::new(); let mut control_point_light_slots = Vec::new(); + let mut control_sound_slots = Vec::new(); + let mut unsupported_control_sounds = 0; let mut effect_documents = HashMap::new(); let mut effect_materials = HashMap::new(); let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len()); @@ -8085,6 +8123,8 @@ fn static_preview_mesh_and_materials( &mut effect_materials, &mut ambient_effect_slots, &mut control_point_light_slots, + &mut control_sound_slots, + &mut unsupported_control_sounds, )?; } } @@ -8231,6 +8271,8 @@ fn static_preview_mesh_and_materials( &mut effect_materials, &mut ambient_effect_slots, &mut control_point_light_slots, + &mut control_sound_slots, + &mut unsupported_control_sounds, )?; } } @@ -8479,6 +8521,8 @@ fn static_preview_mesh_and_materials( material_animations, ambient_effect_slots, control_point_light_slots, + control_sound_slots, + unsupported_control_sounds, component_pose_buffers: component_pose_buffers_by_object, component_mount_poses: component_mount_poses_by_object, dynamic_component_geometry: dynamic_component_geometry_by_object, @@ -9262,7 +9306,9 @@ struct StaticVulkanApp { materials: Vec<VulkanStaticMaterial>, material_animations: Vec<MaterialAnimationBinding>, ambient_effect_slots: Vec<AmbientEffectGpuSlot>, - control_point_light_slots: Vec<ControlPointLightSlot>, + control_point_light_slots: Vec<ControlEffectSlot>, + control_sound_slots: Vec<ControlSoundSlot>, + unsupported_control_sounds: usize, control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>, component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>, component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>, @@ -9367,6 +9413,8 @@ impl StaticVulkanApp { material_animations: preview.material_animations, ambient_effect_slots: preview.ambient_effect_slots, control_point_light_slots: preview.control_point_light_slots, + control_sound_slots: preview.control_sound_slots, + unsupported_control_sounds: preview.unsupported_control_sounds, control_effect_random_states: HashMap::new(), component_pose_buffers: preview.component_pose_buffers, component_mount_poses: preview.component_mount_poses, @@ -9454,6 +9502,7 @@ impl StaticVulkanApp { }) .count(); eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)"); + eprintln!("render_coverage control_op2_supported={} control_op2_unsupported={} (unsupported timing, backend mode or parameters are skipped)", self.control_sound_slots.len(), self.unsupported_control_sounds); eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len()); for &range_index in &self.world_range_indices { let range = self @@ -9840,6 +9889,7 @@ impl StaticVulkanApp { &mut self.control_effect_random_states, &self.ambient_effect_slots, &self.control_point_light_slots, + &self.control_sound_slots, clock.id, control_clock_ms, effect_elapsed_seconds, @@ -10030,6 +10080,47 @@ impl StaticVulkanApp { audio .update_listener(camera.position, camera.forward, camera.up) .map_err(|error| format!("update audio listener: {error}"))?; + for (id, sound) in self.control_sound_slots.iter_mut().enumerate() { + let slot = &mut sound.effect; + if slot.owner_component_is_mounted { + slot.mount_pose = self + .component_mount_poses + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .copied() + .flatten(); + } + let random = control_effect_random_samples + .get(&slot.random_instance_key) + .copied() + .unwrap_or_default(); + let Some(matrix) = control_effect_world_from_component_pose_with_random_shift( + slot, + &self.component_pose_buffers, + random.accumulated_shift, + ) else { + continue; + }; + let Some(phase) = ambient_effect_phase( + slot.time_mode, + slot.header_flags, + effect_elapsed_seconds, + slot.duration_seconds, + ) + .map(|phase| native_command_phase(slot.header_flags, phase, random.phase_jitter)) else { + continue; + }; + if let Some(event) = sound.command.update( + &mut sound.state, + phase, + matrix, + audio.control_sound_is_playing(id), + ) { + audio + .handle_control_sound(id, &event) + .map_err(|error| format!("play Control sound {id}: {error}"))?; + } + } } let environment = if let Some(environment) = self.environment.as_mut() { Some(environment.update_cpu( @@ -13471,7 +13562,7 @@ mod tests { table_index: 0, row_index: 0, }; - let control_slot = ControlPointLightSlot { + let control_slot = ControlEffectSlot { time_mode: 1, header_flags: 9, random_instance_key: key, @@ -13591,6 +13682,8 @@ mod tests { material_animations: vec![material_animation], ambient_effect_slots: Vec::new(), control_point_light_slots: vec![control_slot], + control_sound_slots: Vec::new(), + unsupported_control_sounds: 0, component_pose_buffers: vec![vec![None]], component_mount_poses: vec![vec![None]], dynamic_component_geometry: Vec::new(), @@ -16379,7 +16472,7 @@ mod tests { #[test] fn op1_control_lights_use_native_phase_owner_transform_and_flags() { - let mut slot = ControlPointLightSlot { + let mut slot = ControlEffectSlot { time_mode: 1, header_flags: 0, random_instance_key: ControlEffectInstanceKey { @@ -16460,6 +16553,7 @@ mod tests { &mut states, &[], &sibling_slots, + &[], SceneSampleId::Replay(7), 150, 1.5, @@ -16484,6 +16578,7 @@ mod tests { &mut states, &[], &sibling_slots, + &[], SceneSampleId::Replay(7), 150, 1.5, @@ -16495,6 +16590,7 @@ mod tests { &mut states, &[], &sibling_slots, + &[], SceneSampleId::Replay(8), 200, 0.5, diff --git a/crates/fparkan-fx/src/ambient_sound.rs b/crates/fparkan-fx/src/ambient_sound.rs new file mode 100644 index 0000000..bcc1814 --- /dev/null +++ b/crates/fparkan-fx/src/ambient_sound.rs @@ -0,0 +1,288 @@ +//! Opcode-2 sound commands, independent of an audio device. + +use crate::environment::{SoundAction, SoundEvent}; +use crate::{FxCommand, FxOpcode}; + +/// A validated ordinary Op2 command. Modes 1/3 request an unverified backend +/// flag and are left to the caller's unsupported-command report. +#[derive(Clone, Debug)] +pub struct Op2SoundCommand { + looping: bool, + start: f32, + end: f32, + position: [f32; 3], + position_delta: [f32; 3], + min_distance: f32, + max_distance: f32, + frequency: f32, + frequency_delta: f32, + archive: String, + name: String, +} + +/// Playback crossing state belongs to a command instance, even when several +/// instances share the same sample. +#[derive(Clone, Debug, Default)] +pub struct Op2SoundState { + previous_phase: f32, +} + +impl Op2SoundCommand { + /// Decodes the supported native modes 0 (once) and 2 (loop). + #[must_use] + pub fn from_command(command: &FxCommand) -> Option<Self> { + let body = command.raw_body.as_ref(); + if command.opcode != FxOpcode::Op2 || body.len() != 144 { + return None; + } + let mode = u32::from_le_bytes(body[..4].try_into().ok()?); + // shortcut: backend flag 0x200 and body+76 need a native playback + // comparison before modes 1/3 or nonzero backend parameters are enabled. + if !matches!(mode, 0 | 2) || body[76..80] != [0; 4] { + return None; + } + let float = |offset| { + let value = f32::from_le_bytes(body[offset..offset + 4].try_into().ok()?); + value.is_finite().then_some(value) + }; + let vector = |offset| Some([float(offset)?, float(offset + 4)?, float(offset + 8)?]); + let start = float(4)?; + let end = float(8)?; + let position = vector(12)?; + let position_end = vector(24)?; + let min_distance = float(60)?; + let max_distance = float(64)?; + let frequency = float(68)?; + let frequency_end = float(72)?; + let position_delta = std::array::from_fn(|axis| position_end[axis] - position[axis]); + let frequency_delta = frequency_end - frequency; + if end <= start + || !(end - start).is_finite() + || !position_delta.iter().all(|value: &f32| value.is_finite()) + || !frequency_delta.is_finite() + || min_distance < 0.0 + || max_distance < min_distance + || frequency <= 0.0 + || frequency_end <= 0.0 + { + return None; + } + let text = |bytes: &[u8]| { + let end = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + (!bytes[..end].is_empty()).then(|| String::from_utf8_lossy(&bytes[..end]).into_owned()) + }; + Some(Self { + looping: mode == 2, + start, + end, + position, + position_delta, + min_distance, + max_distance, + frequency, + frequency_delta, + archive: text(&body[80..112])?, + name: text(&body[112..144])?, + }) + } + + /// Evaluates Effect.dll's phase crossings and current source parameters. + /// `playing` comes from the audio owner, since a one-shot can finish between + /// updates. The matrix maps local command coordinates into world space. + #[must_use] + pub fn update( + &self, + state: &mut Op2SoundState, + phase: f32, + world_from_effect: [[f32; 4]; 4], + playing: bool, + ) -> Option<SoundEvent> { + if !phase.is_finite() || !world_from_effect.iter().flatten().all(|v| v.is_finite()) { + return None; + } + let action = if self.looping { + if phase >= self.start && phase <= self.end { + if playing { + SoundAction::SetLoopVolume + } else { + SoundAction::StartLoop + } + } else if playing { + SoundAction::StopLoop + } else { + return None; + } + } else { + let crossing_phase = if phase == 1.0 { 0.0 } else { phase }; + let crossed = state.previous_phase <= crossing_phase + && state.previous_phase <= self.start + && self.start < crossing_phase; + state.previous_phase = crossing_phase; + if crossed { + SoundAction::OneShot + } else if playing { + SoundAction::SetLoopVolume + } else { + return None; + } + }; + let t = (phase - self.start) / (self.end - self.start); + let local: [f32; 3] = + std::array::from_fn(|axis| self.position[axis] + t * self.position_delta[axis]); + let position = std::array::from_fn(|axis| { + let row = world_from_effect[axis]; + row[0] * local[0] + row[1] * local[1] + row[2] * local[2] + row[3] + }); + let frequency_ratio = self.frequency + t * self.frequency_delta; + if !position.iter().all(|value| value.is_finite()) || !frequency_ratio.is_finite() { + return None; + } + Some(SoundEvent { + archive: self.archive.clone(), + name: self.name.clone(), + position, + volume: 1.0, + min_distance: self.min_distance, + max_distance: self.max_distance, + frequency_ratio, + action, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + const IDENTITY: [[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 command(mode: u32) -> FxCommand { + let mut body = vec![0; 144]; + body[..4].copy_from_slice(&mode.to_le_bytes()); + for (offset, value) in [ + (4, 0.25_f32), + (8, 0.75), + (60, 12.0), + (64, 80.0), + (68, 1.0), + (72, 2.0), + ] { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + body[80..90].copy_from_slice(b"sounds.lib"); + body[112..122].copy_from_slice(b"f_defl.wav"); + FxCommand { + word: 2, + opcode: FxOpcode::Op2, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: vec![], + } + } + + #[test] + fn loop_has_inclusive_boundaries_and_updates_without_restarting() { + let command = Op2SoundCommand::from_command(&command(2)).unwrap(); + let mut state = Op2SoundState::default(); + assert!(command.update(&mut state, 0.0, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.25, IDENTITY, false) + .unwrap() + .action, + SoundAction::StartLoop + ); + assert_eq!( + command + .update(&mut state, 0.75, IDENTITY, true) + .unwrap() + .action, + SoundAction::SetLoopVolume + ); + assert_eq!( + command + .update(&mut state, 0.8, IDENTITY, true) + .unwrap() + .action, + SoundAction::StopLoop + ); + assert!(command.update(&mut state, 0.8, IDENTITY, false).is_none()); + } + + #[test] + fn one_shot_crossing_is_strict_and_rearms_after_phase_one() { + let command = Op2SoundCommand::from_command(&command(0)).unwrap(); + let mut state = Op2SoundState::default(); + assert!(command.update(&mut state, 0.25, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.3, IDENTITY, false) + .unwrap() + .action, + SoundAction::OneShot + ); + assert_eq!( + command + .update(&mut state, 0.5, IDENTITY, true) + .unwrap() + .action, + SoundAction::SetLoopVolume + ); + assert!(command.update(&mut state, 0.6, IDENTITY, false).is_none()); + assert!(command.update(&mut state, 1.0, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.3, IDENTITY, false) + .unwrap() + .action, + SoundAction::OneShot + ); + } + + #[test] + fn parameters_keep_native_constant_ranges_and_transform_the_position() { + let mut source = command(2); + let mut body = source.raw_body.to_vec(); + body[24..28].copy_from_slice(&4.0_f32.to_le_bytes()); + source.raw_body = Arc::from(body); + let command = Op2SoundCommand::from_command(&source).unwrap(); + let mut matrix = IDENTITY; + matrix[0] = [0.0, -1.0, 0.0, 10.0]; + matrix[1] = [2.0, 0.0, 0.0, 20.0]; + let event = command + .update(&mut Op2SoundState::default(), 0.5, matrix, false) + .unwrap(); + assert_eq!(event.position, [10.0, 24.0, 0.0]); + assert_eq!( + ( + event.min_distance, + event.max_distance, + event.frequency_ratio + ), + (12.0, 80.0, 1.5) + ); + assert_eq!( + (event.archive.as_str(), event.name.as_str()), + ("sounds.lib", "f_defl.wav") + ); + } + + #[test] + fn backend_modes_and_malformed_commands_are_explicitly_unsupported() { + assert!(Op2SoundCommand::from_command(&command(1)).is_none()); + let mut source = command(2); + let mut body = source.raw_body.to_vec(); + body[8..12].copy_from_slice(&f32::NAN.to_le_bytes()); + source.raw_body = Arc::from(body); + assert!(Op2SoundCommand::from_command(&source).is_none()); + } +} diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 4125a68..af7408a 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -7,6 +7,7 @@ //! are backed by runtime-captured evidence. pub mod ambient_op3; +pub mod ambient_sound; pub mod atmosphere; pub mod environment; pub mod fx_visual; diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 7c9daae..a575baa 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -70,6 +70,10 @@ Control Op1 пока использует цвет и радиус без слу это явное приближение считается в `render_coverage control_op1_jitter_unsupported`. Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены. +Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные +time modes/привязки считаются как `render_coverage control_op2_unsupported`. +Управление фазой экземпляра командами Control остаётся до его подключения в runtime. + Размеры команд, resource references, lifecycle, flags families и используемые time modes известны. Не закрыто значение каждого поля body opcodes 1--10, отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 45d7725..f3ecfb4 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -218,6 +218,41 @@ OneShot -> воспроизвести разовый звук молни отдельный spatial source, применяет DirectSound range/pan и текущий listener. Отсутствие устройства вывода не должно останавливать симуляцию. +### Звуки Control + +Команда Op2 может принадлежать не погоде, а экземпляру эффекта на узле модели. +`fparkan-fx::ambient_sound` хранит её временное окно и предыдущее значение +фазы. В режиме 0 разовый звук начинается при строгом пересечении начала окна: +`previous <= start && start < phase`, если фаза движется вперёд. Значение +`phase == 1` для этой проверки заменяется нулём и подготавливает следующий +цикл. Режим 2 включает loop на обеих границах окна; внутри окна уже работающий +источник обновляется, а не запускается заново. Выход за окно останавливает его. + +Позиция интерполируется между двумя локальными точками и проходит через ту же +матрицу владельца, что и графические команды. Минимальная и максимальная +дистанции остаются постоянными; частота интерполируется по фазе окна. +Каждая команда получает отдельный источник звука, даже если ресурс совпадает +с ресурсом соседней команды. Дождь сохраняет свой loop. Перемещение слушателя, +потеря фокуса и завершение приложения затрагивают все эти источники. + +Эти ветви восстановлены в GOG `Effect.dll` (сборка из +[таблицы бинарников](original-binaries.md)): init RVA `0x12D10`, update +`0x12EB0`. Живой loop `sounds.lib:f_defl.wav` передаёт в звуковой backend +диапазон `12/80`, частоту `1` и мировую позицию из матрицы владельца. +В режиме 2 признак loop берётся из первого DWORD body, а не из command flag. + +Обычное создание эффекта Control сохраняет time mode заголовка и задаёт +постоянную фазу нулём. Raw selector строки служит ID экземпляра, а не номером +режима времени. Поэтому `f_comp_sfx` с time mode 0 и началом окна `0.001` +остаётся тихим до управляющей команды Control. Preview такие команды ещё не +исполняет. Периодические звуки с time mode 2 уже следуют общим часам и текущей +позе узла. `render_coverage control_op2_supported` считает подготовленные +команды, а `control_sound_start/stop` показывает фактические переходы. + +Неизвестные backend modes 1/3, ненулевое поле body+76, неподдержанное время +или привязка владельца пропускаются с `render_coverage control_op2_unsupported`. +Они не заменяются разовым звуком или произвольным loop. + ## Цвет солнца Солнце берёт цвет спрайта и прозрачность бликов из `palette[10]` расписания diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 72b0685..3f98ac4 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -845,6 +845,17 @@ projected shadows. Радиус источника масштабируется синий свет завода почти незаметным. Неположительный итог заменяется на 0,01. Случайные добавки к цвету и радиусу пока явно считаются в `render_coverage control_op1_jitter_unsupported`. + +Эффект может одновременно рисовать поверхность и издавать звук. Для Op2 +приложение создаёт отдельный звуковой источник у каждого экземпляра команды: +несколько одинаковых механизмов слышны одновременно и не заменяют дождь. +Позиция следует анимируемому узлу, а фаза включает, обновляет и останавливает +loop. Обычная строка Control создаёт экземпляр с нулевой постоянной фазой; +звуки с таким режимом ожидают отдельной управляющей команды. Периодические +звуки работают по часам уже в preview. Устройство вывода остаётся в приложении, +а правила переходов и параметров — в `fparkan-fx::ambient_sound` +([подробнее](../reference/environment-effects.md#звуки-control)). + Туман также зависит от расстояния нелинейно: между ближней и дальней границами движок интерполирует по квадрату дистанции. При границах 0 и 700 точка в 350 получает четверть тумана, а не половину. Это сохраняет контраст среднего плана. |
