diff options
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 22 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 402 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/smoke.rs | 1360 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 50 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 21 |
6 files changed, 1818 insertions, 38 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 3fc12a7..3b3969e 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -3841,6 +3841,11 @@ const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT"); const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT"); +// shortcut: the preview does not load shade.cfg yet, so use CShade's native +// defaults until the settings loader is implemented. +const NATIVE_DEFAULT_ROB_SHADOWS_ON: bool = true; +const NATIVE_DEFAULT_BLD_SHADOWS_ON: bool = false; + /// Maps the resolved registry type to the native object-kind selector used by /// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the /// renderer receives the registry provenance from the prepared visual. @@ -3853,6 +3858,14 @@ fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> { } } +fn native_shadow_caster_enabled(native_kind: u32) -> bool { + if native_kind == 4 { + NATIVE_DEFAULT_ROB_SHADOWS_ON + } else { + NATIVE_DEFAULT_BLD_SHADOWS_ON + } +} + fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose { let rotated = rotate_unit_vector(child.translation, parent.rotation); VulkanNodePose { @@ -4288,7 +4301,9 @@ fn static_preview_mesh_and_materials( mesh_components += 1; } } - if let Some(native_kind) = root_native_kind { + if let Some(native_kind) = + root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind)) + { let caster_id = u32::try_from(shadow_casters.len()) .map_err(|_| "shadow caster id exceeds u32".to_string())?; if let Some(caster) = aggregate_shadow_caster( @@ -6439,10 +6454,13 @@ mod tests { } #[test] - fn native_sun_object_source_types_map_to_world_kinds() { + fn native_shadow_kind_mapping_keeps_sun_eligibility_separate_from_caster_defaults() { assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3)); assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4)); assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10)); + assert!(!native_shadow_caster_enabled(3)); + assert!(native_shadow_caster_enabled(4)); + assert!(!native_shadow_caster_enabled(10)); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT))); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU))); assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT))); diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs index babbd0f..a1f6c13 100644 --- a/crates/fparkan-fx/src/environment.rs +++ b/crates/fparkan-fx/src/environment.rs @@ -7,7 +7,7 @@ //! loading behind the simulation. use crate::sky::SkyMaterials; -use crate::{FxDocument, FxError, FxOpcode}; +use crate::{FxCommand, FxDocument, FxError, FxOpcode}; use std::sync::Arc; // Terrain.dll writes these velocity vectors into the shared precipitation @@ -597,12 +597,14 @@ impl LightningBounds { pub struct PointLightFrame { /// Opcode 1 type word. `env_lightning` uses type 7. pub effect_type: u32, - /// Effect age supplied to the native evaluator, in seconds. - pub elapsed_seconds: f32, + /// Native LightManager selector selected from the opcode 1 type. + pub light_selector: u32, + /// Native LightManager flags selected from the opcode 1 type. + pub native_flags: u32, + /// Native update value compared with the opcode 1 start/end fields. + pub command_time: f32, /// Interpolation parameter between the opcode 1 start/end times. pub normalized_time: f32, - /// FXID lifetime in seconds. - pub duration_seconds: f32, /// World-space LightManager position. pub position: [f32; 3], /// World-space direction submitted alongside the point-light record. @@ -616,10 +618,146 @@ pub struct PointLightFrame { /// The scalar at body offset 116. Its native role is an effect factor, /// not alpha or a UV parameter, so it is preserved without reinterpretation. pub effect_factor: f32, - /// Whether the opcode 1 command is active at `elapsed_seconds`. + /// Whether the opcode 1 command is active at `command_time`. pub active: bool, } +/// Evaluates a native opcode 1 point-light command at `command_time` and +/// applies a parent basis to its local position and direction. `basis` stores +/// the world-space vectors corresponding to local +X, +Y, and +Z. +/// `command_time` is resolved by the caller; FX header duration and lifecycle +/// remain with that caller. The interpolated range is retained as authored; +/// native Op1 does not clamp it to zero. +#[must_use] +pub fn evaluate_op1_point_light_with_basis( + command: &FxCommand, + command_time: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], +) -> Option<PointLightFrame> { + if command.opcode != FxOpcode::Op1 + || !command_time.is_finite() + || !origin + .into_iter() + .chain(basis.into_iter().flatten()) + .all(f32::is_finite) + { + return None; + } + + let body = command.raw_body.as_ref(); + const FLOAT_OFFSETS: &[usize] = &[ + 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 108, + 112, 116, 120, 124, 128, + ]; + if body.len() < 132 + || FLOAT_OFFSETS + .iter() + .any(|&offset| read_f32(body, offset).is_none_or(|value| !value.is_finite())) + { + return None; + } + let normalized_time = native_opcode1_interval(body, command_time)?; + if !normalized_time.is_finite() { + return None; + } + let sample = sample_op1_point_light(body, normalized_time)?; + let (light_selector, native_flags) = opcode1_light_manager_mapping(sample.effect_type)?; + if light_selector != 1 { + return None; + } + let transformed_direction = transform3(basis, sample.local_direction); + let position = add3(origin, transform3(basis, sample.local_position)); + if !position.into_iter().all(f32::is_finite) + || !transformed_direction.into_iter().all(f32::is_finite) + || !sample.color.into_iter().all(f32::is_finite) + || !sample.range.is_finite() + || !sample.attenuation.into_iter().all(f32::is_finite) + || !sample.effect_factor.is_finite() + { + return None; + } + let direction = normalize_op1_direction(transformed_direction); + + Some(PointLightFrame { + effect_type: sample.effect_type, + light_selector, + native_flags, + command_time, + normalized_time, + position, + direction, + color: sample.color, + range: sample.range, + attenuation: sample.attenuation, + effect_factor: sample.effect_factor, + active: true, + }) +} + +#[derive(Clone, Copy)] +struct Op1PointLightSample { + effect_type: u32, + local_position: [f32; 3], + local_direction: [f32; 3], + color: [f32; 4], + range: f32, + attenuation: [f32; 3], + effect_factor: f32, +} + +fn sample_op1_point_light(body: &[u8], normalized_time: f32) -> Option<Op1PointLightSample> { + Some(Op1PointLightSample { + effect_type: read_u32(body, 0)?, + local_position: lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time), + local_direction: lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time), + color: lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time), + range: lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time), + attenuation: [ + read_f32(body, 120)?, + read_f32(body, 124)?, + read_f32(body, 128)?, + ], + effect_factor: read_f32(body, 116)?, + }) +} + +fn normalize_op1_direction(direction: [f32; 3]) -> [f32; 3] { + // Native FastProc normalization preserves a degenerate vector as zero; + // this path must not invent a fallback axis. + let length = + (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]) + .sqrt(); + if length.is_finite() && length > 0.0 { + direction.map(|component| component / length) + } else { + [0.0; 3] + } +} + +fn lightning_point_light_direction(local_direction: [f32; 3], basis: [[f32; 3]; 3]) -> [f32; 3] { + // Preserve the pre-existing Lightning consumer fallback, which is + // separate from the generic native Op1 behavior. + finite_unit(transform3(basis, local_direction)) + .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0])) +} + +/// Maps the native opcode 1 type switch to its LightManager record selector +/// and flags. Types 3 and 4 select non-point record classes; generic point +/// lights reject those selectors, while Lightning keeps the full mapping. +fn opcode1_light_manager_mapping(effect_type: u32) -> Option<(u32, u32)> { + Some(match effect_type { + 1 => (1, 0x8000_0000), + 2 => (1, 0), + 3 => (3, 0), + 4 => (4, 0), + 5 => (1, 0), + 6 => (1, 0xA000_0000), + 7 => (1, 0x2000_0000), + _ => return None, + }) +} + /// Native quad produced by the opcode 3 lightning visual. /// /// CLightning creates this effect after moving the sampled position 300 @@ -959,32 +1097,24 @@ impl LightningEffect { } let body = self.opcode1_body.as_ref(); - let effect_type = read_u32(body, 0)?; let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?; - let local_position = lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time); - let local_direction = lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time); - let color = lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time); - let range = lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time); - let attenuation = [ - read_f32(body, 120)?, - read_f32(body, 124)?, - read_f32(body, 128)?, - ]; - let effect_factor = read_f32(body, 116)?; - let direction = finite_unit(transform3(basis, local_direction)) - .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0])); + let sample = sample_op1_point_light(body, normalized_time)?; + let (light_selector, native_flags) = + opcode1_light_manager_mapping(sample.effect_type).unwrap_or((0, 0)); + let direction = lightning_point_light_direction(sample.local_direction, basis); Some(PointLightFrame { - effect_type, - elapsed_seconds, + effect_type: sample.effect_type, + light_selector, + native_flags, + command_time: elapsed_seconds, normalized_time, - duration_seconds: self.duration_seconds, - position: add3(origin, transform3(basis, local_position)), + position: add3(origin, transform3(basis, sample.local_position)), direction, - color: color.map(sanitize_float), - range: sanitize_float(range).max(0.0), - attenuation: attenuation.map(sanitize_float), - effect_factor: sanitize_float(effect_factor), + color: sample.color.map(sanitize_float), + range: sanitize_float(sample.range).max(0.0), + attenuation: sample.attenuation.map(sanitize_float), + effect_factor: sanitize_float(sample.effect_factor), active: true, }) } @@ -1565,7 +1695,9 @@ impl PrecipitationEmitter { let size = camera.precipitation_size_scalar() * size_factor; for index in 0..desired { let particle = &mut self.particles[index]; - particle.previous_position = particle.position; + if dt_seconds > 0.0 { + particle.previous_position = particle.position; + } particle.position[0] += velocity[0] * dt_seconds; particle.position[1] += velocity[1] * dt_seconds; particle.position[2] += velocity[2] * dt_seconds; @@ -1960,6 +2092,31 @@ fn active_interval(body: &[u8], elapsed: f32, duration: f32) -> Option<(f32, f32 Some((normalized, end.min(duration))) } +fn native_opcode1_interval(body: &[u8], command_time: f32) -> Option<f32> { + let start = read_f32(body, 4)?; + let end = read_f32(body, 8)?; + if !command_time.is_finite() + || !start.is_finite() + || !end.is_finite() + || command_time < start + || command_time > end + { + return None; + } + let span = end - start; + if !span.is_finite() { + return None; + } + // Effect.dll divides by the interval span without an epsilon gate + // (1000F703-1000F783). A zero-width interval produces 0/0, which cannot + // yield a finite LightManager sample; keep that command inactive here. + if span == 0.0 { + return None; + } + let normalized = ((command_time - start) / span).clamp(0.0, 1.0); + normalized.is_finite().then_some(normalized) +} + fn evaluate_position(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> { let start = read_vec3(body, 36)?; let delta = read_vec3(body, 48)?; @@ -2315,6 +2472,29 @@ mod tests { } #[test] + fn duplicate_zero_delta_keeps_rain_frame_and_tail_unchanged() { + let mut system = EnvironmentSystem::new(3); + let camera = Camera::new([10.0, 20.0, 30.0]); + let weather = WeatherInput { + rain: WeatherState::with_resources(true, 0.01, [1.0; 4], ["atm_rain1.wav"]), + ..WeatherInput::clear() + }; + let _ = system.update(0.0, weather.clone(), camera); + let advanced = system.update(0.001, weather.clone(), camera); + let time_seconds = system.time_seconds(); + let repeated = system.update(0.0, weather, camera); + + assert_eq!(system.time_seconds(), time_seconds); + assert_eq!(advanced, repeated); + assert!(advanced.primitives.iter().any(|primitive| match primitive { + EnvironmentPrimitive::Particle { screen, .. } => { + screen.world_head != screen.world_tail + } + EnvironmentPrimitive::Lightning(_) => false, + })); + } + + #[test] fn precipitation_uses_native_volume_ratio_and_nearest_even_count() { let reference = PrecipitationVolume::native(); assert_eq!(reference.particle_count_for(reference, 1.0, 0.5004), 500); @@ -2593,6 +2773,172 @@ mod tests { } #[test] + fn generic_opcode1_uses_native_command_time_not_header_duration_phase() { + // These type/start/end values come from the shipped f_blinklight_r + // (header mode 2, duration 2s) and b_sphere_main (mode 0, duration + // 6s) Op1 records. Their local command clocks must reach their own + // authored windows; dividing by the FX lifetime would miss both. + let blinklight = op1_test_command(6, 0.75, 1.0); + let sphere = op1_test_command(5, 1.0 / 3.0, 1.0); + + let blink_midpoint = evaluate_op1_point_light_identity(&blinklight, 0.875, [0.0; 3]) + .expect("blink light command midpoint"); + let sphere_midpoint = evaluate_op1_point_light_identity(&sphere, 2.0 / 3.0, [0.0; 3]) + .expect("sphere command midpoint"); + assert_eq!(blink_midpoint.effect_type, 6); + assert_eq!(blink_midpoint.light_selector, 1); + assert_eq!(blink_midpoint.native_flags, 0xA000_0000); + assert_eq!(blink_midpoint.command_time, 0.875); + assert!((blink_midpoint.normalized_time - 0.5).abs() < 1.0e-6); + assert_eq!(sphere_midpoint.effect_type, 5); + assert_eq!(sphere_midpoint.light_selector, 1); + assert_eq!(sphere_midpoint.native_flags, 0); + assert!((sphere_midpoint.normalized_time - 0.5).abs() < 1.0e-6); + + // The native comparisons accept the endpoint; the effect remains + // active at `end`, where its interpolation reaches one. + let blink_end = evaluate_op1_point_light_identity(&blinklight, 1.0, [0.0; 3]) + .expect("inclusive end of native interval"); + assert_eq!(blink_end.normalized_time, 1.0); + assert!( + evaluate_op1_point_light_identity(&blinklight, 1.0 + f32::EPSILON, [0.0; 3]).is_none() + ); + assert!( + evaluate_op1_point_light_identity(&op1_test_command(3, 0.0, 1.0), 0.5, [0.0; 3]) + .is_none() + ); + assert!( + evaluate_op1_point_light_identity(&op1_test_command(4, 0.0, 1.0), 0.5, [0.0; 3]) + .is_none() + ); + } + + #[test] + fn generic_opcode1_preserves_narrow_native_intervals_and_rejects_zero_span() { + let narrow_end = f32::EPSILON * 0.5; + let narrow = op1_test_command(5, 0.0, narrow_end); + let sample = evaluate_op1_point_light_identity(&narrow, narrow_end * 0.5, [0.0; 3]) + .expect("non-zero native interval below epsilon"); + assert!((sample.normalized_time - 0.5).abs() < 1.0e-6); + + let zero_width = op1_test_command(5, 0.0, 0.0); + assert!(evaluate_op1_point_light_identity(&zero_width, 0.0, [0.0; 3]).is_none()); + } + + #[test] + fn generic_opcode1_preserves_negative_native_range() { + let mut command = op1_test_command(5, 0.0, 1.0); + let mut body = command.raw_body.to_vec(); + body[108..112].copy_from_slice(&(-2.0_f32).to_le_bytes()); + body[112..116].copy_from_slice(&(-4.0_f32).to_le_bytes()); + command.raw_body = Arc::from(body.into_boxed_slice()); + + let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]) + .expect("native Op1 sample with a negative range"); + assert_eq!(sample.range, -3.0); + } + + #[test] + fn generic_opcode1_keeps_zero_direction_zero() { + let mut command = op1_test_command(5, 0.0, 1.0); + let mut body = command.raw_body.to_vec(); + body[36..60].fill(0); + command.raw_body = Arc::from(body.into_boxed_slice()); + + let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]) + .expect("native Op1 sample with a zero direction"); + assert_eq!(sample.direction, [0.0; 3]); + } + + #[test] + fn generic_opcode1_validates_only_values_consumed_by_the_handler() { + let mut command = op1_test_command(7, 0.0, 1.0); + let mut body = command.raw_body.to_vec(); + body[92..96].copy_from_slice(&f32::NAN.to_le_bytes()); + command.raw_body = Arc::from(body.into_boxed_slice()); + assert!(evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]).is_some()); + + let mut body = command.raw_body.to_vec(); + body[80..84].copy_from_slice(&f32::NAN.to_le_bytes()); + command.raw_body = Arc::from(body.into_boxed_slice()); + assert!(evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]).is_none()); + } + + #[test] + fn generic_opcode1_rejects_non_finite_transformed_values() { + let command = op1_test_command(5, 0.0, 1.0); + + assert!(evaluate_op1_point_light_with_basis( + &command, + 0.5, + [f32::MAX, 0.0, 0.0], + [[f32::MAX, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + ) + .is_none()); + } + + fn op1_test_command(effect_type: u32, start: f32, end: f32) -> FxCommand { + let mut body = vec![0; 132]; + body[0..4].copy_from_slice(&effect_type.to_le_bytes()); + body[4..8].copy_from_slice(&start.to_le_bytes()); + body[8..12].copy_from_slice(&end.to_le_bytes()); + body[12..24].copy_from_slice( + &[0.0_f32; 3] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::<Vec<_>>(), + ); + body[24..36].copy_from_slice( + &[2.0_f32, 4.0, 6.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::<Vec<_>>(), + ); + body[36..48].copy_from_slice( + &[1.0_f32, 0.0, 0.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::<Vec<_>>(), + ); + body[48..60].copy_from_slice( + &[0.0_f32, 1.0, 0.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::<Vec<_>>(), + ); + body[60..76].copy_from_slice( + &[1.0_f32, 1.0, 1.0, 1.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::<Vec<_>>(), + ); + body[76..92].copy_from_slice( + &[3.0_f32, 3.0, 3.0, 1.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::<Vec<_>>(), + ); + body[108..112].copy_from_slice(&10.0_f32.to_le_bytes()); + body[112..116].copy_from_slice(&20.0_f32.to_le_bytes()); + body[116..120].copy_from_slice(&0.5_f32.to_le_bytes()); + FxCommand { + word: 1, + opcode: FxOpcode::Op1, + native_flag: false, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + } + } + + fn evaluate_op1_point_light_identity( + command: &FxCommand, + command_time: f32, + origin: [f32; 3], + ) -> Option<PointLightFrame> { + evaluate_op1_point_light_with_basis(command, command_time, origin, identity_basis()) + } + + #[test] fn opcode3_quad_applies_parent_diagonal_before_offset_and_scale() { let mut body = vec![0; 196]; body[0..4].copy_from_slice(&1_u32.to_le_bytes()); diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 04e54ad..4125a68 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -13,6 +13,7 @@ pub mod fx_visual; pub mod op7; pub mod shadow; pub mod sky; +pub mod smoke; /// Advances the native FX 16-bit-pair random state and returns its new high half. /// diff --git a/crates/fparkan-fx/src/smoke.rs b/crates/fparkan-fx/src/smoke.rs new file mode 100644 index 0000000..116dcb7 --- /dev/null +++ b/crates/fparkan-fx/src/smoke.rs @@ -0,0 +1,1360 @@ +//! Deterministic evaluator for verified native opcode-8 particle pools. + +use crate::environment::EffectResource; +use crate::fx_visual::{ + compose_native_effect_matrix, native_camera_facing_basis, native_effect_local_point, + transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV, +}; +use crate::{FxCommand, FxOpcode}; +use std::sync::Arc; + +const RESOURCE_OFFSET: usize = 0xB4; +const RESOURCE_BYTES: usize = 64; +const MIN_BODY_SIZE: usize = RESOURCE_OFFSET + RESOURCE_BYTES; +const MAX_POOL_SIZE: usize = 4096; +const MAX_CATCH_UP: usize = 100_000; +const U16_TO_UNIT: f32 = 1.0 / 65_536.0; + +/// Bounds for the native per-particle detail threshold. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Op8DetailThresholdRange { + /// Lower native threshold bound. + min: f32, + /// Upper native threshold bound. + max: f32, +} + +impl Op8DetailThresholdRange { + /// Returns a range only when both endpoints are finite and ordered. + #[must_use] + pub fn new(min: f32, max: f32) -> Option<Self> { + (min.is_finite() && max.is_finite() && min <= max).then_some(Self { min, max }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct NativeLfsr16Pair { + state: u32, +} + +impl NativeLfsr16Pair { + fn from_word(state: u32) -> Self { + Self { state } + } + + fn next_u16(&mut self) -> u16 { + crate::advance_native_fx_random_state(&mut self.state) + } + + fn next_unit(&mut self) -> f32 { + f32::from(self.next_u16()) * U16_TO_UNIT + } + + fn next_signed(&mut self, width: f32) -> f32 { + (self.next_unit() - 0.5) * width + } +} + +#[derive(Clone, Copy, Debug)] +struct Op8Track { + start: [f32; 3], + end: [f32; 3], + exponent: [f32; 3], +} + +#[derive(Clone, Debug)] +struct ParticleSlot { + born_at: f32, + threshold: f32, + random_phase: f32, + owner_matrix: [[f32; 4]; 4], + position: Op8Track, + scale: Op8Track, +} + +/// Mutable state for one Op8 command. Keep one state per command instance. +#[derive(Clone, Debug)] +pub struct SmokeEmitterState { + initial_emitter_rng: NativeLfsr16Pair, + initial_signed_rng: NativeLfsr16Pair, + initial_material_rng: NativeLfsr16Pair, + emitter_rng: NativeLfsr16Pair, + signed_rng: NativeLfsr16Pair, + material_rng: NativeLfsr16Pair, + body: Option<Arc<[u8]>>, + emitter_position: Option<Op8Track>, + stored_anchor: Option<[f32; 3]>, + cursor: usize, + last_spawn: f32, + interval: f32, + slots: Vec<Option<ParticleSlot>>, +} + +impl SmokeEmitterState { + /// Creates an evaluator with explicit emitter, signed-range, and material seeds. + #[must_use] + pub fn new(emitter_seed: u32, signed_seed: u32, material_seed: u32) -> Self { + let initial_emitter_rng = NativeLfsr16Pair::from_word(emitter_seed); + let initial_signed_rng = NativeLfsr16Pair::from_word(signed_seed); + let initial_material_rng = NativeLfsr16Pair::from_word(material_seed); + Self { + initial_emitter_rng, + initial_signed_rng, + initial_material_rng, + emitter_rng: initial_emitter_rng, + signed_rng: initial_signed_rng, + material_rng: initial_material_rng, + body: None, + emitter_position: None, + stored_anchor: None, + cursor: 0, + last_spawn: 0.0, + interval: 0.0, + slots: Vec::new(), + } + } + + /// Resets the three streams and the native pool state. + fn reset(&mut self) { + self.emitter_rng = self.initial_emitter_rng; + self.signed_rng = self.initial_signed_rng; + self.material_rng = self.initial_material_rng; + self.body = None; + self.emitter_position = None; + self.stored_anchor = None; + self.cursor = 0; + self.last_spawn = 0.0; + self.interval = 0.0; + self.slots.clear(); + } + + /// Number of slots retained by the native ring. + #[must_use] + #[cfg(test)] + fn pool_len(&self) -> usize { + self.slots.len() + } + + /// Native ring cursor. A scheduled event advances this index before access. + #[must_use] + #[cfg(test)] + fn next_slot(&self) -> usize { + self.cursor + } + + fn material_threshold(&mut self, range: Op8DetailThresholdRange) -> f32 { + range.min + self.material_rng.next_unit() * (range.max - range.min) + } +} + +/// Tests the native inclusive detail gate. +#[must_use] +fn detail_class_passes_threshold(detail_class: f32, threshold: f32) -> bool { + detail_class.is_finite() && threshold.is_finite() && detail_class <= threshold +} + +/// Evaluates the native Op8 pool and returns one frame per slot in ring order. +/// +/// The body is parsed from its verified shared offsets, so material names and +/// pool sizes can vary. The caller supplies the native update clock, normalized +/// effect phase, detail threshold bounds, owner transform, camera position, +/// and explicit RNG state. The first in-window call captures the owner pose +/// and emits nothing, matching the native update path. Each emitted particle +/// keeps its interpolated birth anchor and a motion-oriented slot basis. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn evaluate_op8_emitter( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + camera_world: [f32; 3], + update_clock: f32, + effect_phase: f32, + detail_class: u32, + detail_threshold_range: Op8DetailThresholdRange, + state: &mut SmokeEmitterState, +) -> Option<Vec<EffectQuadFrame>> { + if command.opcode != FxOpcode::Op8 + || !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !camera_world.iter().all(|value| value.is_finite()) + || !update_clock.is_finite() + || !effect_phase.is_finite() + || !detail_threshold_range.min.is_finite() + || !detail_threshold_range.max.is_finite() + || detail_threshold_range.min > detail_threshold_range.max + || !(detail_threshold_range.max - detail_threshold_range.min).is_finite() + { + return None; + } + + let params = Op8Params::parse(command)?; + if state + .body + .as_deref() + .is_some_and(|body| body != command.raw_body.as_ref()) + { + state.reset(); + } + state.body = Some(Arc::clone(&command.raw_body)); + + if state.slots.len() != params.pool_count { + if !state.slots.is_empty() { + state.reset(); + state.body = Some(Arc::clone(&command.raw_body)); + } + state.slots.resize_with(params.pool_count, || None); + state.cursor = 0; + } + if state.emitter_position.is_none() { + state.emitter_position = Some(sample_emitter_position(¶ms, state)?); + } + + let in_window = effect_phase >= params.window_start && effect_phase <= params.window_end; + let interval_phase = + normalized_window_phase(effect_phase, params.window_start, params.window_end); + let due_interval = + params.interval_start + (params.interval_end - params.interval_start) * interval_phase; + let schedule_interval = + params.interval_start + (params.interval_end - params.interval_start) * effect_phase; + if !due_interval.is_finite() || due_interval <= 0.0 { + return None; + } + let emitter_offset = sample_track(state.emitter_position?, interval_phase)?; + let current_anchor = apply_emitter_offset(world_from_effect, emitter_offset)?; + + if !in_window && !state.slots.iter().any(Option::is_some) { + // The native update returns early once its live-particle count is zero. + // A later in-window update captures a fresh owner anchor before emitting. + state.stored_anchor = None; + state.last_spawn = 0.0; + state.interval = due_interval; + } else if state.last_spawn == 0.0 { + // Native uses a zero schedule base as its initialization sentinel. + // If the first update clock is zero, the next update captures again. + state.stored_anchor = Some(matrix_translation(¤t_anchor)); + state.last_spawn = update_clock; + state.interval = due_interval; + } else { + // Native clamps only the schedule base on a time rewind. Existing + // particles and all three random streams remain intact. + if state.last_spawn > update_clock { + state.last_spawn = update_clock; + } + let catch_up = catch_up_count( + state.last_spawn, + update_clock, + due_interval, + schedule_interval, + )?; + let schedule_origin = state.last_spawn; + let mut stored_anchor = state.stored_anchor?; + let velocity = if catch_up == 0 { + [0.0; 3] + } else { + owner_anchor_velocity( + ¤t_anchor, + stored_anchor, + update_clock, + schedule_origin, + )? + }; + for _ in 0..catch_up { + let schedule_base = state.last_spawn; + let born_at = schedule_base + due_interval; + let next_schedule_base = schedule_base + schedule_interval; + if !born_at.is_finite() { + return None; + } + if !next_schedule_base.is_finite() || next_schedule_base <= schedule_base { + return None; + } + let (owner_matrix, next_anchor) = + advance_owner_anchor(¤t_anchor, stored_anchor, velocity, due_interval)?; + if in_window { + spawn_particle( + ¶ms, + detail_threshold_range, + born_at, + owner_matrix, + state, + )?; + } else { + retire_particle(params.pool_count, state); + } + stored_anchor = next_anchor; + state.stored_anchor = Some(next_anchor); + state.last_spawn = next_schedule_base; + } + state.interval = due_interval; + } + + let interval_period = state.interval * params.pool_count as f32; + if !interval_period.is_finite() || interval_period <= 0.0 { + return None; + } + let mut frames = Vec::with_capacity(params.pool_count); + + for (slot_index, slot) in state.slots.iter().enumerate() { + let Some(particle) = slot else { + frames.push(empty_frame(command_index, ¶ms.resource)); + continue; + }; + let mut raw_phase = (update_clock - state.last_spawn) / interval_period + + (state.cursor as f32 - slot_index as f32) / params.pool_count as f32; + if raw_phase < 0.0 { + raw_phase += 1.0; + } + if !raw_phase.is_finite() { + return None; + } + + let position = sample_track(particle.position, raw_phase)?; + let scale = sample_track(particle.scale, raw_phase)?; + let opacity = params.opacity_start + + (params.opacity_end - params.opacity_start) * raw_phase.powf(params.opacity_exponent); + let material_phase = if params.material_phase_exponent < 0.0 { + particle.random_phase + } else { + raw_phase.powf(params.material_phase_exponent) + }; + if !opacity.is_finite() || !material_phase.is_finite() { + return None; + } + + let local_position = position; + let detail_passes = detail_class_passes_threshold(detail_class as f32, particle.threshold); + let camera_local = native_effect_local_point(&particle.owner_matrix, camera_world)?; + let view = subtract3(camera_local, local_position); + let Some(local_basis) = native_camera_facing_basis(view, local_position) else { + frames.push(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op8, + material: params.resource.clone(), + vertices: [[0.0; 3]; 4], + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, 0.0], + material_phase, + elapsed_seconds: (update_clock - particle.born_at).max(0.0), + normalized_time: raw_phase, + active: false, + native_flag_base: None, + }); + continue; + }; + let matrix = compose_native_effect_matrix( + &particle.owner_matrix, + &local_basis, + local_position, + scale, + )?; + let vertices = transform_native_effect_quad(&matrix)?; + let visible = detail_passes; + frames.push(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op8, + material: params.resource.clone(), + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, if visible { opacity } else { 0.0 }], + material_phase, + elapsed_seconds: (update_clock - particle.born_at).max(0.0), + normalized_time: raw_phase, + active: visible, + native_flag_base: None, + }); + } + Some(frames) +} + +#[derive(Clone, Debug)] +struct Op8Params { + resource: EffectResource, + pool_count: usize, + window_start: f32, + window_end: f32, + interval_start: f32, + interval_end: f32, + material_phase_exponent: f32, + opacity_start: f32, + opacity_end: f32, + opacity_exponent: f32, + emitter_position_start: [f32; 3], + emitter_position_end_center: [f32; 3], + emitter_position_width: [f32; 3], + emitter_position_exponent: [f32; 3], + position_start: [f32; 3], + position_end_center: [f32; 3], + position_width: [f32; 3], + position_exponent: [f32; 3], + scale_start: [f32; 3], + scale_end_center: [f32; 3], + scale_width: [f32; 3], + scale_exponent: [f32; 3], +} + +impl Op8Params { + fn parse(command: &FxCommand) -> Option<Self> { + let body = &command.raw_body; + if body.len() < MIN_BODY_SIZE { + return None; + } + let pool_count = read_u32(body, 0x20)? as usize; + let window_start = read_f32(body, 0x0C)?; + let window_end = read_f32(body, 0x10)?; + let interval_start = read_f32(body, 0x14)?; + let interval_end = read_f32(body, 0x18)?; + let material_phase_exponent = read_f32(body, 0x1C)?; + let opacity_start = read_f32(body, 0x00)?; + let opacity_end = read_f32(body, 0x04)?; + let opacity_exponent = read_f32(body, 0x08)?; + let emitter_position_start = read_vec3(body, 0x24)?; + let emitter_position_end_center = read_vec3(body, 0x30)?; + let emitter_position_width = read_vec3(body, 0x3C)?; + let emitter_position_exponent = read_vec3(body, 0x48)?; + let position_start = read_vec3(body, 0x54)?; + let position_end_center = read_vec3(body, 0x60)?; + let position_width = read_vec3(body, 0x6C)?; + let position_exponent = read_vec3(body, 0x78)?; + let scale_start = read_vec3(body, 0x84)?; + let scale_end_center = read_vec3(body, 0x90)?; + let scale_width = read_vec3(body, 0x9C)?; + let scale_exponent = read_vec3(body, 0xA8)?; + if pool_count == 0 + || pool_count > MAX_POOL_SIZE + || ![ + window_start, + window_end, + interval_start, + interval_end, + material_phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + ] + .iter() + .all(|value| value.is_finite()) + || window_start > window_end + || interval_start <= 0.0 + || interval_end <= 0.0 + || !emitter_position_start + .iter() + .chain(emitter_position_end_center.iter()) + .chain(emitter_position_width.iter()) + .chain(emitter_position_exponent.iter()) + .chain(position_start.iter()) + .chain(position_end_center.iter()) + .chain(position_width.iter()) + .chain(position_exponent.iter()) + .chain(scale_start.iter()) + .chain(scale_end_center.iter()) + .chain(scale_width.iter()) + .chain(scale_exponent.iter()) + .all(|value| value.is_finite()) + { + return None; + } + + Some(Self { + resource: read_resource(body)?, + pool_count, + window_start, + window_end, + interval_start, + interval_end, + material_phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + emitter_position_start, + emitter_position_end_center, + emitter_position_width, + emitter_position_exponent, + position_start, + position_end_center, + position_width, + position_exponent, + scale_start, + scale_end_center, + scale_width, + scale_exponent, + }) + } +} + +fn normalized_window_phase(phase: f32, start: f32, end: f32) -> f32 { + let phase = (phase - start) / (end - start); + if !(phase >= 0.0) { + 0.0 + } else if phase > 1.0 { + 1.0 + } else { + phase + } +} + +fn catch_up_count( + last_spawn: f32, + update_clock: f32, + due_interval: f32, + schedule_interval: f32, +) -> Option<usize> { + if !last_spawn.is_finite() || !update_clock.is_finite() || !due_interval.is_finite() { + return None; + } + if due_interval <= 0.0 { + return None; + } + + let mut scheduled = last_spawn; + let mut count = 0; + loop { + let due_at = scheduled + due_interval; + if !due_at.is_finite() { + return None; + } + if !(due_at < update_clock) { + return Some(count); + } + if count == MAX_CATCH_UP || !schedule_interval.is_finite() || schedule_interval <= 0.0 { + return None; + } + let next_base = scheduled + schedule_interval; + if !next_base.is_finite() || next_base <= scheduled { + return None; + } + scheduled = next_base; + count += 1; + } +} + +fn sample_emitter_position(params: &Op8Params, state: &mut SmokeEmitterState) -> Option<Op8Track> { + // Native 11343..113A4 draws Z from the uniform stream first and centers + // it around zero, then draws centered Y and X from the signed-range stream. + let mut end = params.emitter_position_end_center; + end[2] += state.emitter_rng.next_unit() * params.emitter_position_width[2] + - params.emitter_position_width[2] * 0.5; + end[1] += state + .signed_rng + .next_signed(params.emitter_position_width[1]); + end[0] += state + .signed_rng + .next_signed(params.emitter_position_width[0]); + end.iter() + .all(|value| value.is_finite()) + .then_some(Op8Track { + start: params.emitter_position_start, + end, + exponent: params.emitter_position_exponent, + }) +} + +fn apply_emitter_offset(owner: &[[f32; 4]; 4], offset: [f32; 3]) -> Option<[[f32; 4]; 4]> { + let mut anchor = *owner; + for axis in 0..3 { + anchor[axis][3] += offset[axis]; + } + anchor + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(anchor) +} + +fn spawn_particle( + params: &Op8Params, + threshold_range: Op8DetailThresholdRange, + born_at: f32, + owner_matrix: [[f32; 4]; 4], + state: &mut SmokeEmitterState, +) -> Option<()> { + let threshold = state.material_threshold(threshold_range); + let random_phase = state.emitter_rng.next_unit(); + let position_z = params.position_end_center[2] + + state.emitter_rng.next_unit() * params.position_width[2] + - params.position_width[2] * 0.5; + let position_y = + params.position_end_center[1] + state.signed_rng.next_signed(params.position_width[1]); + let position_x = + params.position_end_center[0] + state.signed_rng.next_signed(params.position_width[0]); + let scale_z = params.scale_end_center[2] + state.signed_rng.next_signed(params.scale_width[2]); + let scale_y = params.scale_end_center[1] + state.signed_rng.next_signed(params.scale_width[1]); + let scale_x = params.scale_end_center[0] + state.signed_rng.next_signed(params.scale_width[0]); + let position_end = [position_x, position_y, position_z]; + let scale_end = [scale_x, scale_y, scale_z]; + if !threshold.is_finite() + || !position_end.iter().all(|value| value.is_finite()) + || !scale_end.iter().all(|value| value.is_finite()) + || !born_at.is_finite() + { + return None; + } + // The native ring increments its cursor before using the slot. + state.cursor = (state.cursor + 1) % params.pool_count; + state.slots[state.cursor] = Some(ParticleSlot { + born_at, + threshold, + random_phase, + owner_matrix, + position: Op8Track { + start: params.position_start, + end: position_end, + exponent: params.position_exponent, + }, + scale: Op8Track { + start: params.scale_start, + end: scale_end, + exponent: params.scale_exponent, + }, + }); + Some(()) +} + +fn retire_particle(pool_count: usize, state: &mut SmokeEmitterState) { + // Out-of-window ticks follow the same cursor cadence, but mark the slot + // reached by that tick inactive instead of emitting a replacement. + state.cursor = (state.cursor + 1) % pool_count; + state.slots[state.cursor] = None; +} + +fn matrix_translation(matrix: &[[f32; 4]; 4]) -> [f32; 3] { + [matrix[0][3], matrix[1][3], matrix[2][3]] +} + +fn owner_anchor_velocity( + current: &[[f32; 4]; 4], + stored_anchor: [f32; 3], + update_clock: f32, + schedule_base: f32, +) -> Option<[f32; 3]> { + let elapsed = update_clock - schedule_base; + if !elapsed.is_finite() || elapsed <= 0.0 { + return None; + } + let velocity = std::array::from_fn(|axis| (current[axis][3] - stored_anchor[axis]) / elapsed); + velocity + .iter() + .all(|value| value.is_finite()) + .then_some(velocity) +} + +fn advance_owner_anchor( + current: &[[f32; 4]; 4], + stored_anchor: [f32; 3], + velocity: [f32; 3], + due_interval: f32, +) -> Option<([[f32; 4]; 4], [f32; 3])> { + let mut velocity = velocity; + if vector_length_squared(velocity)? == 0.0 { + // Native replaces the zero velocity vector with the current owner's X + // column before both stepping the anchor and building the slot frame. + velocity = [current[0][0], current[1][0], current[2][0]]; + } + let translation = + std::array::from_fn(|axis| stored_anchor[axis] + velocity[axis] * due_interval); + let tangent = normalize_vector(velocity)?; + + let owner_up = [current[0][2], current[1][2], current[2][2]]; + let side_candidate = cross(owner_up, tangent); + let side = if vector_length_squared(side_candidate)? == 0.0 { + native_fallback_side(tangent)? + } else { + normalize_vector(side_candidate)? + }; + let up = cross(tangent, side); + let matrix = [ + [tangent[0], side[0], up[0], translation[0]], + [tangent[1], side[1], up[1], translation[1]], + [tangent[2], side[2], up[2], translation[2]], + [0.0, 0.0, 0.0, 1.0], + ]; + (matrix.iter().flatten().all(|value| value.is_finite()) + && translation.iter().all(|value| value.is_finite())) + .then_some((matrix, translation)) +} + +fn vector_length_squared(vector: [f32; 3]) -> Option<f32> { + let length_squared = vector + .iter() + .map(|component| component * component) + .sum::<f32>(); + length_squared.is_finite().then_some(length_squared) +} + +fn normalize_vector(vector: [f32; 3]) -> Option<[f32; 3]> { + let length_squared = vector_length_squared(vector)?; + if length_squared == 0.0 { + return None; + } + let inverse_length = length_squared.sqrt().recip(); + let normalized = vector.map(|component| component * inverse_length); + normalized + .iter() + .all(|component| component.is_finite()) + .then_some(normalized) +} + +fn native_fallback_side(tangent: [f32; 3]) -> Option<[f32; 3]> { + // Match 38D0: choose X when tangent X is zero, otherwise Y when tangent + // Y is zero, otherwise use (-Y, X, 0) and normalize only when Z is nonzero. + let mut side = if tangent[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if tangent[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-tangent[1], tangent[0], 0.0] + }; + if tangent[0] != 0.0 && tangent[1] != 0.0 && tangent[2] != 0.0 { + side = normalize_vector(side)?; + } + Some(side) +} + +fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn sample_track(track: Op8Track, phase: f32) -> Option<[f32; 3]> { + let sample = std::array::from_fn(|axis| { + track.start[axis] + (track.end[axis] - track.start[axis]) * phase.powf(track.exponent[axis]) + }); + sample + .iter() + .all(|value| value.is_finite()) + .then_some(sample) +} + +fn empty_frame(command_index: usize, resource: &EffectResource) -> EffectQuadFrame { + EffectQuadFrame { + command_index, + opcode: FxOpcode::Op8, + material: resource.clone(), + vertices: [[0.0; 3]; 4], + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, 0.0], + material_phase: 0.0, + elapsed_seconds: 0.0, + normalized_time: 0.0, + active: false, + native_flag_base: None, + } +} + +fn read_resource(body: &[u8]) -> Option<EffectResource> { + let bytes = body.get(RESOURCE_OFFSET..RESOURCE_OFFSET + RESOURCE_BYTES)?; + Some(EffectResource { + archive: read_name(&bytes[..32])?, + name: read_name(&bytes[32..])?, + }) +} + +fn read_name(bytes: &[u8]) -> Option<String> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + let name = std::str::from_utf8(&bytes[..end]).ok()?.trim(); + (!name.is_empty()).then(|| name.to_owned()) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> { + Some(f32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(bytes, offset)?, + read_f32(bytes, offset + 4)?, + read_f32(bytes, offset + 8)?, + ]) +} + +fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + std::array::from_fn(|axis| left[axis] - right[axis]) +} + +#[cfg(test)] +mod tests { + use super::{ + detail_class_passes_threshold, evaluate_op8_emitter, Op8DetailThresholdRange, + SmokeEmitterState, + }; + use crate::{fx_visual::EffectQuadFrame, FxCommand, FxOpcode}; + 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(resource: &str, pool_count: u32, native_flag: bool) -> FxCommand { + let mut body = vec![0_u8; 0xF4]; + put_f32(&mut body, 0x00, 1.0); + put_f32(&mut body, 0x04, 0.0); + put_f32(&mut body, 0x08, 1.0); + put_f32(&mut body, 0x0C, 0.0); + put_f32(&mut body, 0x10, 1.0); + put_f32(&mut body, 0x14, 0.08); + put_f32(&mut body, 0x18, 0.08); + put_f32(&mut body, 0x1C, 1.0); + put_u32(&mut body, 0x20, pool_count); + put_vec3(&mut body, 0x24, [0.0; 3]); + put_vec3(&mut body, 0x30, [0.0; 3]); + put_vec3(&mut body, 0x3C, [0.0; 3]); + put_vec3(&mut body, 0x48, [1.0; 3]); + put_vec3(&mut body, 0x54, [0.0; 3]); + put_vec3(&mut body, 0x60, [0.0, 0.0, 50.0]); + put_vec3(&mut body, 0x6C, [10.0, 30.0, 30.0]); + put_vec3(&mut body, 0x78, [1.0; 3]); + put_vec3(&mut body, 0x84, [10.0; 3]); + put_vec3(&mut body, 0x90, [30.0; 3]); + put_vec3(&mut body, 0x9C, [0.0, 1.0, 1.0]); + put_vec3(&mut body, 0xA8, [1.0; 3]); + put_name(&mut body, 0xB4, "material.lib"); + put_name(&mut body, 0xD4, resource); + FxCommand { + word: if native_flag { 0x108 } else { 8 }, + opcode: FxOpcode::Op8, + native_flag, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } + } + + fn evaluate( + command: &FxCommand, + clock: f32, + phase: f32, + detail_class: u32, + state: &mut SmokeEmitterState, + ) -> Vec<EffectQuadFrame> { + evaluate_with_owner(command, clock, phase, detail_class, &IDENTITY, state) + } + + fn evaluate_with_owner( + command: &FxCommand, + clock: f32, + phase: f32, + detail_class: u32, + owner: &[[f32; 4]; 4], + state: &mut SmokeEmitterState, + ) -> Vec<EffectQuadFrame> { + evaluate_op8_emitter( + command, + 0, + owner, + [0.0, 0.0, 500.0], + clock, + phase, + detail_class, + Op8DetailThresholdRange::new(1.0, 1.0).unwrap(), + state, + ) + .unwrap() + } + + fn put_u32(body: &mut [u8], offset: usize, value: u32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_f32(body: &mut [u8], offset: usize, value: f32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) { + for (axis, component) in value.into_iter().enumerate() { + put_f32(body, offset + axis * 4, component); + } + } + + fn put_name(body: &mut [u8], offset: usize, value: &str) { + body[offset..offset + value.len()].copy_from_slice(value.as_bytes()); + } + + fn centroid(frame: &EffectQuadFrame) -> [f32; 3] { + std::array::from_fn(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + / 4.0 + }) + } + + fn distance(left: [f32; 3], right: [f32; 3]) -> f32 { + let delta = std::array::from_fn::<_, 3, _>(|axis| left[axis] - right[axis]); + (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt() + } + + #[test] + fn first_update_strict_interval_and_catch_up_match_native_schedule() { + let command = command("fire_smoke", 30, false); + let mut state = SmokeEmitterState::new(13, 13, 13); + assert!(evaluate(&command, 0.0, 0.0, 0, &mut state) + .iter() + .all(|frame| !frame.active)); + assert_eq!(state.next_slot(), 0); + assert_eq!(state.last_spawn, 0.0); + assert_eq!( + evaluate(&command, 0.08, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 0 + ); + assert_eq!(state.last_spawn, 0.08); + assert_eq!( + evaluate(&command, 0.16, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 0 + ); + assert_eq!( + evaluate(&command, 0.160_001, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 1 + ); + assert_eq!( + evaluate(&command, 0.320_001, 0.0, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 3 + ); + assert_eq!(state.next_slot(), 3); + } + + #[test] + fn b_sphere_start_uses_normalized_due_interval_and_raw_schedule_step() { + let mut command = command("B_Sphere_Start", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + // These values are from effects.rlb / B_Sphere_Start command 14. + put_f32(body, 0x0C, 0.269); + put_f32(body, 0x10, 0.885); + put_f32(body, 0x14, 0.05); + put_f32(body, 0x18, 0.01); + let mut state = SmokeEmitterState::new(17, 29, 43); + + evaluate(&command, 0.01, 0.5, 0, &mut state); + evaluate(&command, 0.09, 0.5, 0, &mut state); + + // At phase .5, native normalizes to .375 and tests events at .035, + // then .065 relative to the captured .01 base. After each event it + // advances the schedule base by the raw-phase interval .03. + assert!((state.last_spawn - 0.07).abs() < 1e-6); + assert_eq!(state.next_slot(), 2); + assert!((state.slots[1].as_ref().unwrap().born_at - 0.045).abs() < 1e-6); + assert!((state.slots[2].as_ref().unwrap().born_at - 0.075).abs() < 1e-6); + } + + #[test] + fn equal_window_phase_matches_native_unordered_and_clamp_comparisons() { + assert_eq!(super::normalized_window_phase(0.269, 0.269, 0.269), 0.0); + assert_eq!(super::normalized_window_phase(0.268, 0.269, 0.269), 0.0); + assert_eq!(super::normalized_window_phase(0.270, 0.269, 0.269), 1.0); + } + + #[test] + fn clock_rewind_clamps_only_schedule_base() { + let command = command("smoke_yg", 6, true); + let mut state = SmokeEmitterState::new(17, 29, 43); + evaluate(&command, 0.01, 0.5, 0, &mut state); + evaluate(&command, 0.090_001, 0.5, 0, &mut state); + + let cursor = state.cursor; + let born_at = state.slots[1].as_ref().unwrap().born_at; + let emitter_rng = state.emitter_rng; + let signed_rng = state.signed_rng; + let material_rng = state.material_rng; + assert!((state.last_spawn - 0.09).abs() < 1e-6); + + evaluate(&command, 0.04, 0.5, 0, &mut state); + + assert_eq!(state.last_spawn, 0.04); + assert_eq!(state.cursor, cursor); + assert_eq!(state.slots[1].as_ref().unwrap().born_at, born_at); + assert_eq!(state.emitter_rng, emitter_rng); + assert_eq!(state.signed_rng, signed_rng); + assert_eq!(state.material_rng, material_rng); + } + + #[test] + fn catch_up_requires_advancing_base_and_honors_validation_bound() { + assert_eq!(super::catch_up_count(0.0, 1.0, 0.5, 0.25), Some(2)); + assert!(super::catch_up_count(0.0, 200_001.0, 1.0, 1.0).is_none()); + assert!(super::catch_up_count(16_777_216.0, 16_777_220.0, 0.01, 0.01).is_none()); + } + + #[test] + fn moving_owner_advances_from_the_last_emitted_anchor() { + let command = command("smoke_yg", 6, true); + let owner_start = IDENTITY; + let mut owner_end = [ + [0.0, -1.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let mut owner_later = IDENTITY; + owner_end[0][3] = 10.0; + owner_later[0][3] = 20.0; + let mut state = SmokeEmitterState::new(17, 17, 17); + + evaluate_with_owner(&command, 0.01, 0.0, 0, &owner_start, &mut state); + evaluate_with_owner(&command, 0.05, 0.5, 0, &owner_end, &mut state); + assert_eq!(state.stored_anchor.unwrap(), [0.0, 0.0, 0.0]); + + // No scheduled event has happened, so the stored anchor stays at the + // initialization pose. Native divides the full displacement by the + // time from that anchor's schedule base when an event becomes due. + evaluate_with_owner(&command, 0.110_001, 0.5, 0, &owner_end, &mut state); + let slot = state.slots[1].as_ref().unwrap(); + assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4); + assert_eq!( + state.stored_anchor.unwrap(), + [slot.owner_matrix[0][3], 0.0, 0.0] + ); + // The native slot frame uses motion direction and current owner Z. + // The yawed owner with X-directed motion yields an identity basis. + assert_eq!( + slot.owner_matrix[0], + [1.0, 0.0, 0.0, slot.owner_matrix[0][3]] + ); + assert_eq!(slot.owner_matrix[1], [0.0, 1.0, 0.0, 0.0]); + assert_eq!(slot.owner_matrix[2], [0.0, 0.0, 1.0, 0.0]); + + let frames = evaluate_with_owner(&command, 0.12, 0.6, 0, &owner_later, &mut state); + let slot = state.slots[1].as_ref().unwrap(); + let local_position = super::sample_track(slot.position, frames[1].normalized_time).unwrap(); + // A no-event update leaves the previously emitted anchor unchanged. + assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4); + assert!((state.stored_anchor.unwrap()[0] - 8.0).abs() < 1e-4); + let center_x = centroid(&frames[1])[0]; + assert!((center_x - (slot.owner_matrix[0][3] + local_position[0])).abs() < 1e-4); + assert!((center_x - (owner_later[0][3] + local_position[0])).abs() > 10.0); + + let frames = evaluate_with_owner(&command, 0.250_001, 0.7, 0, &owner_later, &mut state); + assert!((state.slots[2].as_ref().unwrap().born_at - 0.17).abs() < 1e-6); + assert!((state.slots[3].as_ref().unwrap().born_at - 0.25).abs() < 1e-6); + // Both catch-up particles use the single velocity computed from the + // stored X=8 anchor and the original schedule base, stepping by .08. + assert!((state.slots[2].as_ref().unwrap().owner_matrix[0][3] - 14.0).abs() < 1e-3); + assert!((state.slots[3].as_ref().unwrap().owner_matrix[0][3] - 20.0).abs() < 1e-3); + assert!(frames[2].active && frames[3].active); + } + + #[test] + fn moving_owner_uses_native_zero_motion_and_parallel_axis_fallbacks() { + let command = command("smoke_yg", 6, true); + let previous = IDENTITY; + let mut current = IDENTITY; + // Tiny nonzero movement must remain a velocity, not take the exact + // zero-motion branch. A parallel owner-up axis then exercises 38D0. + current[0][3] = 0.000_01; + current[0][2] = 1.0; + current[2][2] = 0.0; + current[0][0] = 0.0; + current[1][0] = 1.0; + current[2][0] = 0.0; + let mut state = SmokeEmitterState::new(19, 19, 19); + evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut state); + evaluate_with_owner(&command, 0.090_001, 0.5, 0, ¤t, &mut state); + let matrix = state.slots[1].as_ref().unwrap().owner_matrix; + assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [1.0, 0.0, 0.0]); + assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [0.0, 1.0, 0.0]); + assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]); + + // Exactly zero movement uses the current owner X column, matching the + // separate 11B83 branch before the 38D0 parallel-axis fallback. + let mut static_owner = IDENTITY; + static_owner[0][0] = 0.0; + static_owner[1][0] = 1.0; + static_owner[2][0] = 0.0; + let mut stationary = SmokeEmitterState::new(23, 23, 23); + evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut stationary); + evaluate_with_owner(&command, 0.090_001, 0.5, 0, &static_owner, &mut stationary); + let matrix = stationary.slots[1].as_ref().unwrap().owner_matrix; + assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [0.0, 1.0, 0.0]); + assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [-1.0, 0.0, 0.0]); + assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]); + assert!((matrix[1][3] - 0.08).abs() < 1e-6); + assert_eq!(stationary.stored_anchor.unwrap(), [0.0, 0.08, 0.0]); + } + + #[test] + fn particles_drain_through_the_ring_and_keep_fading_after_the_window() { + let mut command = command("smoke_yg", 6, true); + put_f32(Arc::make_mut(&mut command.raw_body), 0x10, 0.5); + let mut state = SmokeEmitterState::new(31, 31, 31); + + evaluate(&command, 0.01, 0.25, 0, &mut state); + assert_eq!( + evaluate(&command, 0.090_001, 0.25, 0, &mut state) + .iter() + .filter(|frame| frame.active) + .count(), + 1 + ); + + let tail = evaluate(&command, 0.170_001, 0.75, 0, &mut state); + assert_eq!(tail.iter().filter(|frame| frame.active).count(), 1); + assert!(tail[1].color[3] > 0.0 && tail[1].color[3] < 1.0); + + // Out-of-window events still advance the native ring. This particle + // remains alive until the cursor reaches its slot on the sixth tick. + let before_wrap = evaluate(&command, 0.490_001, 0.75, 0, &mut state); + assert_eq!(before_wrap.iter().filter(|frame| frame.active).count(), 1); + assert!(before_wrap[1].active); + assert!(before_wrap[1].color[3] < tail[1].color[3]); + + let drained = evaluate(&command, 0.570_001, 0.75, 0, &mut state); + assert!(drained.iter().all(|frame| !frame.active)); + assert!(state.slots.iter().all(Option::is_none)); + } + + #[test] + fn generic_six_slot_smoke_yg_body_samples_tracks_opacity_and_phase() { + let mut command = command("smoke_yg", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + put_f32(body, 0x00, 0.25); + put_f32(body, 0x04, 0.75); + put_f32(body, 0x08, 2.0); + put_f32(body, 0x1C, -1.0); + put_f32(body, 0x14, 0.08); + put_f32(body, 0x18, 0.08); + put_f32(body, 0x44, 0.0); + put_vec3(body, 0x48, [1.0; 3]); + put_vec3(body, 0x24, [0.0, 0.0, 3.0]); + put_vec3(body, 0x30, [0.0, 0.0, 3.0]); + put_vec3(body, 0x54, [0.0, 0.0, 3.0]); + put_vec3(body, 0x60, [0.0, 0.0, 13.0]); + put_vec3(body, 0x6C, [0.0, 0.0, 6.0]); + put_vec3(body, 0x78, [1.0, 1.0, 0.5]); + put_vec3(body, 0x84, [0.5, 0.75, 1.0]); + put_vec3(body, 0x90, [1.5, 1.75, 2.0]); + put_vec3(body, 0x9C, [0.0; 3]); + put_vec3(body, 0xA8, [1.0, 2.0, 3.0]); + + let mut state = SmokeEmitterState::new(17, 29, 43); + let first = evaluate(&command, 0.01, 0.0, 1, &mut state); + assert_eq!(first.len(), 6); + assert!(first.iter().all(|frame| !frame.active)); + let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state); + let frame = &frames[1]; + let particle = state.slots[1].as_ref().unwrap(); + let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0); + let position: [f32; 3] = std::array::from_fn(|axis| { + particle.position.start[axis] + + (particle.position.end[axis] - particle.position.start[axis]) + * phase.powf(particle.position.exponent[axis]) + }); + let scale: [f32; 3] = std::array::from_fn(|axis| { + particle.scale.start[axis] + + (particle.scale.end[axis] - particle.scale.start[axis]) + * phase.powf(particle.scale.exponent[axis]) + }); + let expected_center = + crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position) + .unwrap(); + let expected_opacity = 0.25 + (0.75 - 0.25) * phase.powf(2.0); + let camera_local = + crate::fx_visual::native_effect_local_point(&particle.owner_matrix, [0.0, 0.0, 500.0]) + .unwrap(); + let view = std::array::from_fn(|axis| camera_local[axis] - position[axis]); + let local_basis = super::native_camera_facing_basis(view, position).unwrap(); + let expected_edge_length = |basis_column| { + let edge: [f32; 3] = std::array::from_fn(|row| { + (0..3) + .map(|axis| { + particle.owner_matrix[row][axis] + * scale[axis] + * local_basis[axis][basis_column] + }) + .sum::<f32>() + }); + (edge[0] * edge[0] + edge[1] * edge[1] + edge[2] * edge[2]).sqrt() + }; + + assert!(frame.active); + assert_eq!(frame.material.name, "smoke_yg"); + assert!((frame.normalized_time - phase).abs() < 1e-6); + assert!((frame.material_phase - particle.random_phase).abs() < 1e-6); + assert!((frame.color[3] - expected_opacity).abs() < 1e-6); + for (actual, expected) in centroid(frame).into_iter().zip(expected_center) { + assert!( + (actual - expected).abs() < 1e-4, + "actual={actual}, expected={expected}" + ); + } + assert!( + (distance(frame.vertices[0], frame.vertices[3]) - expected_edge_length(0)).abs() < 1e-4 + ); + assert!( + (distance(frame.vertices[0], frame.vertices[1]) - expected_edge_length(1)).abs() < 1e-4 + ); + } + + #[test] + fn mission02_breath_anl_03_op8_fixture_uses_shared_body_fields() { + let mut command = command("smoke_yg", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + put_f32(body, 0x44, 0.0); + put_vec3(body, 0x54, [0.0, 0.0, 1.0]); + put_vec3(body, 0x60, [0.0, 0.0, 3.0]); + put_vec3(body, 0x6C, [3.0, 2.0, 1.0]); + put_vec3(body, 0x78, [1.0; 3]); + put_vec3(body, 0x84, [4.0; 3]); + put_vec3(body, 0x90, [5.0; 3]); + put_vec3(body, 0x9C, [1.0; 3]); + put_vec3(body, 0xA8, [1.0; 3]); + + let mut state = SmokeEmitterState::new(17, 29, 43); + evaluate(&command, 0.01, 0.0, 1, &mut state); + let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state); + let frame = &frames[1]; + let particle = state.slots[1].as_ref().unwrap(); + let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0); + let position = super::sample_track(particle.position, phase).unwrap(); + + assert!(frame.active); + assert_eq!(frame.material.name, "smoke_yg"); + assert!((frame.normalized_time - phase).abs() < 1e-6); + assert!((frame.material_phase - phase).abs() < 1e-6); + assert!((frame.color[3] - (1.0 - phase)).abs() < 1e-6); + let expected_center = + crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position) + .unwrap(); + for (actual, expected) in centroid(frame).into_iter().zip(expected_center) { + assert!((actual - expected).abs() < 1e-4); + } + } + + #[test] + fn generic_body_uses_dynamic_resource_pool_and_wraps_ring() { + let command = command("cloud_B2_ADD", 6, false); + let mut left = SmokeEmitterState::new(77, 77, 77); + let mut right = SmokeEmitterState::new(77, 77, 77); + evaluate(&command, 0.01, 0.0, 0, &mut left); + evaluate(&command, 0.01, 0.0, 0, &mut right); + let left_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut left); + let right_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut right); + assert_eq!(left.pool_len(), 6); + assert_eq!(left.next_slot(), 1); + assert_eq!(left_frames, right_frames); + assert_eq!(left_frames[0].material.name, "cloud_B2_ADD"); + } + + #[test] + fn emitter_position_track_moves_birth_anchor_from_start_to_randomized_end() { + let mut command = command("smoke_yg", 6, true); + let body = Arc::make_mut(&mut command.raw_body); + put_vec3(body, 0x24, [1.0, 2.0, 3.0]); + put_vec3(body, 0x30, [11.0, 22.0, 33.0]); + put_vec3(body, 0x3C, [4.0, 6.0, 8.0]); + put_vec3(body, 0x48, [1.0, 2.0, 3.0]); + + let uniform_seed = 0x0001_0000; + let signed_seed = 0x0002_0000; + let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1); + evaluate(&command, 0.01, 0.0, 0, &mut state); + let track = state.emitter_position.unwrap(); + let signed = |word: f32, width: f32| (word / 65_536.0 - 0.5) * width; + let expected_end = [ + 11.0 + signed(6.0, 4.0), + 22.0 + signed(3.0, 6.0), + 33.0 + 1.0 / 65_536.0 * 8.0 - 4.0, + ]; + assert_eq!(track.start, [1.0, 2.0, 3.0]); + for (actual, expected) in track.end.into_iter().zip(expected_end) { + assert!((actual - expected).abs() < 1e-6); + } + let mut expected_uniform_rng = super::NativeLfsr16Pair::from_word(uniform_seed); + let mut expected_signed_rng = super::NativeLfsr16Pair::from_word(signed_seed); + expected_uniform_rng.next_unit(); + expected_signed_rng.next_signed(6.0); + expected_signed_rng.next_signed(4.0); + assert_eq!(state.emitter_rng, expected_uniform_rng); + assert_eq!(state.signed_rng, expected_signed_rng); + + // The midpoint uses each component's own exponent, and the sampled + // offset is added to the owner translation before the slot is born. + let midpoint = super::sample_track(track, 0.5).unwrap(); + let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1); + evaluate(&command, 0.01, 0.0, 0, &mut state); + let mut owner = IDENTITY; + owner[0][3] = 5.0; + let frames = evaluate_with_owner(&command, 0.090_001, 0.5, 0, &owner, &mut state); + let particle = state.slots[1].as_ref().unwrap(); + let expected_position_z = 50.0 + 4.0 / 65_536.0 * 30.0 - 15.0; + assert!((particle.position.end[2] - expected_position_z).abs() < 1e-6); + // The spawn is almost at the current update, whose owner anchor is + // the trajectory sample at phase 0.5. + for axis in 0..3 { + assert!( + (particle.owner_matrix[axis][3] - (owner[axis][3] + midpoint[axis])).abs() < 1e-3 + ); + } + let local_position = + super::sample_track(particle.position, frames[1].normalized_time).unwrap(); + let expected_center = + crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, local_position) + .unwrap(); + for (actual, expected) in centroid(&frames[1]).into_iter().zip(expected_center) { + assert!((actual - expected).abs() < 1e-3); + } + } + + #[test] + fn detail_gate_is_inclusive_and_rejects_the_next_float() { + assert!(detail_class_passes_threshold(3.0, 3.0)); + let next_float = f32::from_bits(3.0_f32.to_bits() + 1); + assert!(!detail_class_passes_threshold(next_float, 3.0)); + assert!(detail_class_passes_threshold(0.0, 0.0)); + } + + #[test] + fn rejects_malformed_or_oversized_op8_bodies_before_state_changes() { + let command = FxCommand { + word: 8, + opcode: FxOpcode::Op8, + native_flag: false, + raw_body: Arc::from(vec![0_u8; 32]), + resource_refs: Vec::new(), + }; + let mut state = SmokeEmitterState::new(13, 13, 13); + assert!(evaluate_op8_emitter( + &command, + 0, + &IDENTITY, + [0.0, 0.0, 500.0], + 1.0, + 0.0, + 0, + Op8DetailThresholdRange::new(0.0, 3.0).unwrap(), + &mut state, + ) + .is_none()); + assert_eq!(state.pool_len(), 0); + assert_eq!(state.next_slot(), 0); + } +} diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 078a222..0435a6a 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -104,6 +104,10 @@ N = round_even( координаты, и к каждой оси применяется floor-based wrap; 5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову. +Повторное обновление на том же времени имеет нулевой `dt`: позиция капли и +сохранённое начало хвоста остаются прежними. Хвост продвигается только при +положительном шаге; wrap по-прежнему сбрасывает его на голову. + Скорости не принадлежат системе координат камеры: ```text @@ -143,6 +147,24 @@ slot `+6c`): ширина `RECT` делится на горизонтальныÐ исходную таблицу знаков и минимальный depth fade `0.1`. Renderer сам решает, как загрузить этот квадрат и как смешать его материал. +## Точечный свет из opcode 1 + +Preview вычисляет generic Op1 отдельно от визуальных команд. Переданный +владельцем `command_time` сравнивается с началом и концом интервала из body +opcode 1; обе границы включены, а коэффициент выборки равен +`(command_time - start) / (end - start)`. Заголовочная длительность FXID не +заменяет этот интервал. Для нулевой ширины адаптер не создаёт кадр света, +поскольку у команды нет конечного коэффициента интерполяции. + +Позиция, направление, цвет и range интерполируются по концам команды. +Позиция и направление переводятся через основу родителя; направление после +этого нормализуется, а нулевой вектор остаётся нулевым. Range сохраняет +авторское значение без ограничения снизу. Типы Op1 `1`, `2`, `5`, `6` и `7` +выбирают point-light selector `1`; их native flags сохраняются. Типы `3` и +`4` выбирают другие классы записей LightManager и не попадают в generic список +точечного света. У `LightningEffect` выборка тех же полей использует отдельные +ограничение по длительности FXID и правила безопасной обработки. + ## Молния и звуковые события `decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и @@ -285,6 +307,34 @@ Evaluator типа 2 принимает индекс context явно и учиÑ семена отдельных экземпляров лишь детерминированно заменяют process-global состояние и не воспроизводят общий порядок вызовов. +## Частицы Op8 + +`fparkan-fx::smoke` разбирает body Op8, хранит кольцевой пул команды и выдаёт +quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная +фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события; +сравнение строгое (`schedule_base + interval < update_clock`). Все события +catch-up в одном обновлении проверяются по тому же нормализованному интервалу; +после события нативный код продвигает базу расписания интервалом, вычисленным +по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу. +Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, который +нативный unordered-путь сравнения ограничивает нулём. + +Нулевая база расписания служит признаком начального состояния: обновление +сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если +первое время равно нулю, следующее обновление повторно захватывает опору. При +событии скорость опоры считается от сохранённой позиции до текущей за время +от базы расписания; слот смещает сохранённую опору на `скорость * due_interval`. +Обновление без событий не переносит сохранённую опору к текущему владельцу. +Откат часов корректирует только базу расписания до текущего времени: слоты, +опора и три потока случайных +чисел не сбрасываются. + +Evaluator принимает отдельные начальные состояния для эмиттера, знаковых +отклонений и материала. Переход каждого потока совпадает с общим native LFSR, +но эти локальные seed не воспроизводят порядок process-global вызовов игры. +Предел 100000 событий catch-up — защитная граница evaluator-а; соответствующего +лимита в исследованном native цикле не обнаружено. + ## Что остаётся за владельцем `EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 15fb01a..c8bdf4e 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -625,15 +625,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж отдельным slot или texture stage. Замена lightmap предварительным умножением в diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию. -Vulkan preview строит projected shadows для выбранных зданий и роботов, -отбрасывает spheres вне шести clip-плоскостей с радиусом `sphere + 100` и +Vulkan preview строит проецируемые тени для объектов, отбрасывающих тени, +отбрасывает сферы вне шести clip-плоскостей с радиусом `sphere + 100` и сохраняет порядок видимых кандидатов; frame builder обрабатывает не более 20 -casters. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page -slots и использует одну smoothing pass. Вызов preview передаёт `fade = 1.0` и -LOD defaults: minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и -роботов. Rust-путь не загружает `shade.cfg`: эти значения задаются кодом, а -параметры файла пока не влияют на preview. Проекция учитывает вклад -directional и point lights. +объектов. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page +slots и использует одно сглаживание. Preview применяет native defaults +`RobShadowsOn = true` и `BldShadowsOn = false`: объект kind `4` проверяет +первый флаг, остальные kinds — второй. Поэтому роботы BTLU остаются объектами, +отбрасывающими тени, а объекты FORT и STAT их не создают. Preview пока не +загружает `shade.cfg`, поэтому значения из пользовательского файла не меняют +список объектов, отбрасывающих тени. Проверка действует только на проецируемые +тени: отдельный список объектов для затенения солнца по-прежнему включает +FORT, BTLU и STAT. Вызов preview передаёт `fade = 1.0` и LOD defaults: +minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и роботов. +Проекция учитывает вклад directional и point lights. Receiver projection обрезает projected quad по рёбрам треугольника. Native ray query descriptor в `+0x10` требует наличия всех переданных bits, а descriptor |
