diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-fx/src/atmosphere.rs | 38 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/fx_visual.rs | 1428 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/fparkan-material/src/lib.rs | 57 | ||||
| -rw-r--r-- | crates/fparkan-terrain-format/src/lib.rs | 346 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 48 |
6 files changed, 1903 insertions, 15 deletions
diff --git a/crates/fparkan-fx/src/atmosphere.rs b/crates/fparkan-fx/src/atmosphere.rs index 2681b00..da3a98b 100644 --- a/crates/fparkan-fx/src/atmosphere.rs +++ b/crates/fparkan-fx/src/atmosphere.rs @@ -1139,8 +1139,7 @@ fn sky_sample(key: &InterpolatedKey) -> SkySample { fn sun_sample(key: &InterpolatedKey, birth: Option<SunBirth>) -> SunSample { let source = key.colors[12].rgba(); let intensity = key.parameters[2]; - // Native selector-0 outputs 5/6 are palette[9]/[14] (Terrain.dll RVA 0x3DFB0); - // CSun uses slot 5 alpha for flare opacity (+0x4B3AB) and slot 6 RGB for secondary light (+0x4B4BE). + // CSun's sprite and flare opacity use palette[10]; its secondary light uses palette[14]. SunSample { active: birth.is_some(), rgb: [ @@ -1149,7 +1148,7 @@ fn sun_sample(key: &InterpolatedKey, birth: Option<SunBirth>) -> SunSample { source[2] * intensity, ], values: [key.parameters[0], key.parameters[1]], - packed: [key.colors[9], key.colors[14]], + packed: [key.colors[10], key.colors[14]], intensity, birth, } @@ -1590,8 +1589,8 @@ mod tests { #[test] fn sun_packed_slots_feed_native_flare_alpha_and_secondary_light() { let mut palette = [0xFF00_0000_u32; KEY_COLOR_COUNT]; - palette[9] = 0x4011_2233; - palette[10] = 0xFFAA_BBCC; + palette[9] = 0xFF00_0000; + palette[10] = 0x4011_2233; palette[12] = 0xFFFF_FFFF; palette[14] = 0x8044_5566; let bytes = document(2, |out| { @@ -1608,7 +1607,7 @@ mod tests { let sampled = parsed.sample_day(0, 0.0); assert!(sampled.sun.active); - assert_eq!(sampled.sun.packed[0], PackedColor(palette[9])); + assert_eq!(sampled.sun.packed[0], PackedColor(palette[10])); assert_eq!(sampled.sun.packed[1], PackedColor(palette[14])); let birth = sampled.sun.birth.expect("sun birth"); @@ -1617,7 +1616,7 @@ mod tests { 0.0, crate::sky::SunConfig::from_birth(birth), ); - assert_eq!(sun.sprite_color, PackedColor(palette[9]).rgba()); + assert_eq!(sun.sprite_color, PackedColor(palette[10]).rgba()); assert_eq!(sun.secondary_color, PackedColor(palette[14]).rgba()[..3]); let optics = crate::sky::sun_optics( @@ -1635,6 +1634,31 @@ mod tests { } #[test] + fn sun_sprite_uses_interpolated_palette_ten() { + let mut first = [0xFF00_0000_u32; KEY_COLOR_COUNT]; + first[9] = 0xFF00_0000; + first[10] = 0x32FF_2800; + first[12] = 0xFFFF_FFFF; + let mut second = first; + second[10] = 0x4BFF_4A00; + let bytes = document(2, |out| { + key(out, 0, 0, 0, first, [0.0, 0.0, 1.0, 0.0], &[]); + key(out, 12, 0, 1, second, [0.0, 0.0, 1.0, 0.0], &[]); + }); + let mut parsed = TypedAtmosphere::parse(&bytes).expect("parse"); + for key in &mut parsed.tracks[0].keys { + key.strings[0] = AtmosphereString { + raw: b"sun".to_vec(), + text: "sun".to_owned(), + }; + } + + let sampled = parsed.sample_day(0, 7.2 * 3_600.0); + assert_eq!(sampled.sun.packed[0], PackedColor(0x41FF_3C00)); + assert_ne!(sampled.sun.packed[0], PackedColor(first[9])); + } + + #[test] fn wraps_last_key_to_first_and_emits_events() { let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT]; let bytes = document(2, |out| { diff --git a/crates/fparkan-fx/src/fx_visual.rs b/crates/fparkan-fx/src/fx_visual.rs new file mode 100644 index 0000000..0c09d4a --- /dev/null +++ b/crates/fparkan-fx/src/fx_visual.rs @@ -0,0 +1,1428 @@ +//! Shared, native-shaped output for FX commands that render one four-vertex +//! effect primitive. +//! +//! This module keeps the small affine and frame contracts separate from the +//! environment-specific lightning emitter. Command evaluators only belong +//! here once their native inputs and output fields are established. + +use crate::environment::EffectResource; +use crate::{FxCommand, FxOpcode}; + +/// Canonical native four-vertex topology in helper-local coordinates. +/// +/// The consumer's triangle list is `[0, 1, 2, 2, 3, 0]`. +const EFFECT_QUAD_CORNERS: [[f32; 3]; 4] = [ + [-0.5, 0.5, 0.0], + [-0.5, -0.5, 0.0], + [0.5, -0.5, 0.0], + [0.5, 0.5, 0.0], +]; + +/// Native full-texture quad coordinates, with the 0.99 edge inset. +pub(crate) const EFFECT_QUAD_UV: [[f32; 2]; 4] = + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]]; + +/// One evaluated native-style FX quad. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectQuadFrame { + /// Source command index in the FXID. + pub command_index: usize, + /// Source command opcode. + pub opcode: FxOpcode, + /// MAT0 resource selected by the command. + pub material: EffectResource, + /// World-space vertices in native corner order. + pub vertices: [[f32; 3]; 4], + /// Native texture coordinates in matching corner order. + pub uv: [[f32; 2]; 4], + /// Per-vertex RGBA value supplied by the native command evaluator. + /// + /// The alpha is the native FX opacity value. Native render assignment + /// replaces MAT0 phase alpha with this value rather than multiplying it. + pub color: [f32; 4], + /// Wrapped command phase supplied to the material's MAT0 animation. + pub material_phase: f32, + /// Effect age in seconds. + pub elapsed_seconds: f32, + /// Normalized command time over its native active interval. + pub normalized_time: f32, + /// Whether the native command is active at `elapsed_seconds`. + pub active: bool, + /// Category-independent native draw-flag base selected by Op9 body C4. + /// + /// The native renderer combines this base with render-context category + /// bits and a trailing command flag. Those inputs are not part of this + /// geometry evaluator's interface yet, so this stores only the proven + /// selector mapping (C4 0/1/2 -> 3/5/6). + pub native_flag_base: Option<u32>, +} + +/// One evaluated indexed mesh emitted by a native FX command. +/// +/// Position and UV arrays use the native element order; `indices` expands +/// those elements into triangles for renderers that consume indexed lists. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectMeshFrame { + /// Source command index in the FXID. + pub command_index: usize, + /// Source command opcode. + pub opcode: FxOpcode, + /// MAT0 resource selected by the command. + pub material: EffectResource, + /// World-space mesh positions in native element order. + pub vertices: Vec<[f32; 3]>, + /// Texture coordinates paired with `vertices`. + pub uv: Vec<[f32; 2]>, + /// Triangle-list indices into `vertices` and `uv`. + pub indices: Vec<u16>, + /// Per-vertex RGBA value supplied by the native command evaluator. + pub color: [f32; 4], + /// Wrapped command phase supplied to the material's MAT0 animation. + pub material_phase: f32, + /// Effect age in seconds. + pub elapsed_seconds: f32, + /// Normalized command time over its native active interval. + pub normalized_time: f32, + /// Whether the native command is active at `elapsed_seconds`. + pub active: bool, + /// Category-independent selector base from Op9 body+C4. + pub native_flag_base: u32, + /// Native category selected by the top two bits of the renderer flags. + pub native_category: u8, +} + +const F_GENER_BALL_RESOURCE_OFFSET: usize = 0x84; +const F_GENER_BALL_RESOURCE_BYTES: usize = 64; + +struct FGenerBallSample { + opcode: FxOpcode, + material: EffectResource, + matrix: [[f32; 4]; 4], + color: [f32; 4], + material_phase: f32, + elapsed_seconds: f32, + normalized_time: f32, + active: bool, + native_flag_base: Option<u32>, +} + +/// Evaluates a supported opcode-4 `f_gener_ball` billboard quad. +/// +/// `world_from_effect` is the row-major native parent matrix after owner +/// transforms and the FX header scale have been applied. `effect_phase` is the +/// normalized overall effect age; `elapsed_seconds` is command age in seconds. +/// `camera_world` is the render-context query point consumed by the native +/// opcode-4 path. Opcode 9 emits generated hemisphere geometry and must use +/// [`evaluate_f_gener_ball_mesh`] instead. +/// +/// Opcode 4 body mode 0 is supported when its distance denominator at +/// body+0xC4 is positive. Other mode/body combinations return `None` until +/// their native branches are verified. +#[must_use] +pub fn evaluate_f_gener_ball( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + effect_phase: f32, + elapsed_seconds: f32, + camera_world: [f32; 3], +) -> Option<EffectQuadFrame> { + if command.opcode != FxOpcode::Op4 { + return None; + } + let sample = evaluate_f_gener_ball_sample( + command, + world_from_effect, + effect_phase, + elapsed_seconds, + camera_world, + )?; + let vertices = transform_native_effect_quad(&sample.matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: sample.opcode, + material: sample.material, + vertices, + uv: EFFECT_QUAD_UV, + color: sample.color, + elapsed_seconds: sample.elapsed_seconds, + normalized_time: sample.normalized_time, + material_phase: sample.material_phase, + active: sample.active, + native_flag_base: sample.native_flag_base, + }) +} + +/// Evaluates an Op9 `f_gener_ball` command as the native generated hemisphere. +/// +/// `native_category` is `(renderer_flags >> 30)`, the native category index +/// consumed by Terrain's geometry selector. The three C4 selectors map to +/// generated-mesh modes 0, 1, and 2. Body+0xC8 selects the native UV path: +/// zero uses per-vertex projected XY coordinates; nonzero uses Terrain's +/// static quad/triangle UV layouts. Op4 remains a billboard quad and is not +/// accepted here. +#[must_use] +pub fn evaluate_f_gener_ball_mesh( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + effect_phase: f32, + elapsed_seconds: f32, + camera_world: [f32; 3], + native_category: u8, +) -> Option<EffectMeshFrame> { + if command.opcode != FxOpcode::Op9 || native_category > 3 { + return None; + } + let sample = evaluate_f_gener_ball_sample( + command, + world_from_effect, + effect_phase, + elapsed_seconds, + camera_world, + )?; + let native_flag_base = sample.native_flag_base?; + let static_uv = read_u32(&command.raw_body, 0xC8)? != 0; + let (vertices, uv, indices) = + generated_op9_hemisphere(native_flag_base, native_category, static_uv)?; + let vertices = vertices + .into_iter() + .map(|vertex| transform_native_effect_point(&sample.matrix, vertex)) + .collect::<Option<Vec<_>>>()?; + + Some(EffectMeshFrame { + command_index, + opcode: sample.opcode, + material: sample.material, + vertices, + uv, + indices, + color: sample.color, + material_phase: sample.material_phase, + elapsed_seconds: sample.elapsed_seconds, + normalized_time: sample.normalized_time, + active: sample.active, + native_flag_base, + native_category, + }) +} + +fn evaluate_f_gener_ball_sample( + command: &FxCommand, + world_from_effect: &[[f32; 4]; 4], + effect_phase: f32, + elapsed_seconds: f32, + camera_world: [f32; 3], +) -> Option<FGenerBallSample> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !effect_phase.is_finite() + || !elapsed_seconds.is_finite() + { + return None; + } + if !matches!(command.opcode, FxOpcode::Op9 | FxOpcode::Op4) { + return None; + } + + let body = &command.raw_body; + let mode = read_u32(body, 0x00)?; + let (billboard, distance_denominator, native_flag_base) = match command.opcode { + FxOpcode::Op9 if mode == 2 => { + let selector = read_u32(body, 0xC4)?; + let flag_base = match selector { + 0 => 3, + 1 => 5, + 2 => 6, + _ => return None, + }; + (false, None, Some(flag_base)) + } + FxOpcode::Op4 if mode == 0 => { + let denominator = read_f32(body, 0xC4)?; + if !denominator.is_finite() || denominator <= 0.0 { + return None; + } + (true, Some(denominator), None) + } + _ => return None, + }; + + let resource = read_effect_resource(body)?; + let start = read_f32(body, 0x1C)?; + let end = read_f32(body, 0x20)?; + let phase_start_raw = read_f32(body, 0x04)?; + let phase_end = read_f32(body, 0x08)?; + let phase_exponent = read_f32(body, 0x0C)?; + let opacity_start = read_f32(body, 0x10)?; + let opacity_end = read_f32(body, 0x14)?; + let opacity_exponent = read_f32(body, 0x18)?; + if ![ + start, + end, + phase_start_raw, + phase_end, + phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + ] + .iter() + .all(|value| value.is_finite()) + || start > end + { + return None; + } + + let interval_time = command_interval_phase(effect_phase, start, end); + let animation_time = if let Some(denominator) = distance_denominator { + if !camera_world.iter().all(|value| value.is_finite()) { + return None; + } + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let distance = vector_length(camera_local)?; + (interval_time * distance / denominator).min(1.0) + } else { + interval_time + }; + if !animation_time.is_finite() { + return None; + } + + let position_start = read_vec3(body, 0x24)?; + let position_end = read_vec3(body, 0x30)?; + let position_exponents = read_vec3(body, 0x3C)?; + let axis_start = read_vec3(body, 0x48)?; + let axis_end = read_vec3(body, 0x54)?; + let scale_start = read_vec3(body, 0x60)?; + let scale_end = read_vec3(body, 0x6C)?; + // Native pushes these exponent fields in reverse stack order; their + // resulting XYZ pairing is body+0x80, +0x7C, +0x78. + let scale_exponents = [ + read_f32(body, 0x80)?, + read_f32(body, 0x7C)?, + read_f32(body, 0x78)?, + ]; + + let position = curve_vector( + position_start, + position_end, + position_exponents, + animation_time, + )?; + let axis = lerp_vector(axis_start, axis_end, animation_time); + let scale = curve_vector(scale_start, scale_end, scale_exponents, animation_time)?; + let opacity_curve_time = animation_time.powf(opacity_exponent); + let opacity = + (opacity_start + (opacity_end - opacity_start) * opacity_curve_time).clamp(0.0, 1.0); + + let material_timebase = if phase_start_raw < 0.0 { + elapsed_seconds + } else { + interval_time + }; + let phase_start = phase_start_raw.max(0.0); + let phase = phase_start + (phase_end - phase_start) * material_timebase.powf(phase_exponent); + let material_phase = phase.rem_euclid(1.0); + if !opacity.is_finite() || !material_phase.is_finite() { + return None; + } + + let local_basis = if billboard { + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let view = subtract_vector(camera_local, position); + native_camera_facing_basis(view, position)? + } else { + native_d110_basis(axis, position)? + }; + let matrix = compose_native_effect_matrix(world_from_effect, &local_basis, position, scale)?; + let active = effect_phase >= start && effect_phase <= end; + + Some(FGenerBallSample { + opcode: command.opcode, + material: resource, + matrix, + color: [1.0, 1.0, 1.0, opacity], + elapsed_seconds, + normalized_time: animation_time, + material_phase, + active, + native_flag_base, + }) +} + +// Terrain::18520's native mode/category tables, indexed by +// `mode * 4 + (renderer_flags >> 30)`. These values are assigned from the +// Terrain globals at initialization and drive both 18D40 draw loops. +const OP9_SECTOR_COUNTS: [[usize; 4]; 3] = [[8, 6, 5, 4], [16, 12, 10, 8], [24, 20, 16, 10]]; +const OP9_RING_COUNTS: [[usize; 4]; 3] = [[3, 3, 2, 1], [6, 5, 4, 3], [9, 8, 6, 4]]; + +fn generated_op9_hemisphere( + native_flag_base: u32, + native_category: u8, + static_uv: bool, +) -> Option<(Vec<[f32; 3]>, Vec<[f32; 2]>, Vec<u16>)> { + let mode = match native_flag_base { + 3 => 0, + 5 => 1, + 6 => 2, + _ => return None, + }; + let category = usize::from(native_category); + if category >= 4 { + return None; + } + + let sectors = OP9_SECTOR_COUNTS[mode][category]; + let rings = OP9_RING_COUNTS[mode][category]; + let quad_count = sectors.checked_mul(rings.checked_sub(1)?)?; + let vertex_count = quad_count + .checked_mul(4)? + .checked_add(sectors.checked_mul(3)?)?; + let index_count = quad_count + .checked_mul(6)? + .checked_add(sectors.checked_mul(3)?)?; + let mut vertices = Vec::with_capacity(vertex_count); + let mut uv = Vec::with_capacity(vertex_count); + let mut indices = Vec::with_capacity(index_count); + let latitude_step = std::f32::consts::FRAC_PI_2 / rings as f32; + + // 18310 stores quads sector-major, then ring-major. Each cell's order is + // top/current-azimuth, top/next-azimuth, bottom/next, bottom/current. + for sector in 0..sectors { + let phi0 = std::f32::consts::TAU * sector as f32 / sectors as f32; + let phi1 = std::f32::consts::TAU * (sector + 1) as f32 / sectors as f32; + for ring in 0..rings.saturating_sub(1) { + let theta0 = latitude_step * (ring + 1) as f32; + let theta1 = latitude_step * (ring + 2) as f32; + let element_start = u16::try_from(vertices.len()).ok()?; + let element = [ + hemisphere_point(theta0, phi0), + hemisphere_point(theta0, phi1), + hemisphere_point(theta1, phi1), + hemisphere_point(theta1, phi0), + ]; + for (corner, point) in element.into_iter().enumerate() { + uv.push(if static_uv { + EFFECT_QUAD_UV[corner] + } else { + [(point[0] + 1.0) * 0.5, (point[1] + 1.0) * 0.5] + }); + vertices.push(point); + } + indices.extend_from_slice(&[ + element_start, + element_start + 1, + element_start + 2, + element_start + 2, + element_start + 3, + element_start, + ]); + } + } + + // 18190 appends the cap fan after all quads, ordered pole, next sector, + // current sector. The UVs are the same native XY projection as 18310. + for sector in 0..sectors { + let phi0 = std::f32::consts::TAU * sector as f32 / sectors as f32; + let phi1 = std::f32::consts::TAU * (sector + 1) as f32 / sectors as f32; + let theta = latitude_step; + let element_start = u16::try_from(vertices.len()).ok()?; + let element = [ + [0.0, 0.0, 1.0], + hemisphere_point(theta, phi1), + hemisphere_point(theta, phi0), + ]; + let static_triangle_uv = [[0.5, 0.99], [0.0, 0.0], [0.99, 0.0]]; + for (corner, point) in element.into_iter().enumerate() { + uv.push(if static_uv { + static_triangle_uv[corner] + } else { + [(point[0] + 1.0) * 0.5, (point[1] + 1.0) * 0.5] + }); + vertices.push(point); + } + indices.extend_from_slice(&[element_start, element_start + 1, element_start + 2]); + } + + (vertices.len() == vertex_count && indices.len() == index_count) + .then_some((vertices, uv, indices)) +} + +fn hemisphere_point(theta: f32, phi: f32) -> [f32; 3] { + let radial = theta.sin(); + [radial * phi.sin(), radial * phi.cos(), theta.cos()] +} + +pub(crate) fn native_camera_facing_basis( + view: [f32; 3], + position: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !view + .iter() + .chain(position.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let length = vector_length(view)?; + if length == 0.0 { + // The native 93F4 branch clears its active byte and returns false when + // camera and animated position coincide. + return None; + } + let view = [view[0] / length, view[1] / length, view[2] / length]; + let mut u = if view[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if view[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-view[1], view[0], 0.0] + }; + if view[2] != 0.0 && view[0] != 0.0 && view[1] != 0.0 { + let u_length = vector_length(u)?; + if u_length == 0.0 { + return None; + } + for component in &mut u { + *component /= u_length; + } + } + let v = cross(view, u); + Some([ + [u[0], v[0], view[0], position[0]], + [u[1], v[1], view[1], position[1]], + [u[2], v[2], view[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ]) +} + +fn multiply_affine(left: &[[f32; 4]; 4], right: &[[f32; 4]; 4]) -> Option<[[f32; 4]; 4]> { + let result = std::array::from_fn(|row| { + std::array::from_fn(|column| { + (0..4) + .map(|inner| left[row][inner] * right[inner][column]) + .sum::<f32>() + }) + }); + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn read_effect_resource(body: &[u8]) -> Option<EffectResource> { + let bytes = body.get( + F_GENER_BALL_RESOURCE_OFFSET..F_GENER_BALL_RESOURCE_OFFSET + F_GENER_BALL_RESOURCE_BYTES, + )?; + let archive = bounded_name(&bytes[..32])?; + let name = bounded_name(&bytes[32..])?; + (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name }) +} + +fn bounded_name(bytes: &[u8]) -> Option<String> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + let value = std::str::from_utf8(&bytes[..end]).ok()?.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn read_u32(body: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + body.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_f32(body: &[u8], offset: usize) -> Option<f32> { + Some(f32::from_le_bytes( + body.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset + 4)?, + read_f32(body, offset + 8)?, + ]) +} + +fn command_interval_phase(phase: f32, start: f32, end: f32) -> f32 { + if phase < start { + 0.0 + } else if end == start { + 1.0 + } else if phase >= end { + 1.0 + } else { + ((phase - start) / (end - start)).clamp(0.0, 1.0) + } +} + +/// Builds the native D110 Type2 local basis in row-major matrix form. +/// +/// The X/Y degeneracies use native constant fallbacks. For a general axis U +/// is normalized only when its Z component is nonzero; V is `axis × U`, and +/// the axis vector itself remains unnormalized. +#[must_use] +pub(crate) fn native_d110_basis(axis: [f32; 3], position: [f32; 3]) -> Option<[[f32; 4]; 4]> { + if !axis + .iter() + .chain(position.iter()) + .all(|value| value.is_finite()) + { + return None; + } + + let mut u = if axis[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if axis[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-axis[1], axis[0], 0.0] + }; + if axis[2] != 0.0 && axis[0] != 0.0 && axis[1] != 0.0 { + let length = vector_length(u)?; + if length == 0.0 { + return None; + } + for value in &mut u { + *value /= length; + } + } + + let v = cross(axis, u); + let result = [ + [u[0], v[0], axis[0], position[0]], + [u[1], v[1], axis[1], position[1]], + [u[2], v[2], axis[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ]; + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn curve_vector(start: [f32; 3], end: [f32; 3], exponent: [f32; 3], time: f32) -> Option<[f32; 3]> { + let result = std::array::from_fn(|axis| { + start[axis] + (end[axis] - start[axis]) * time.powf(exponent[axis]) + }); + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn lerp_vector(start: [f32; 3], end: [f32; 3], time: f32) -> [f32; 3] { + std::array::from_fn(|axis| start[axis] + (end[axis] - start[axis]) * time) +} + +fn vector_length(vector: [f32; 3]) -> Option<f32> { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + length.is_finite().then_some(length) +} + +fn subtract_vector(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +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], + ] +} + +/// Applies the native affine point transform used by the quad helper. +/// +/// The matrix is row-major. Each output component is the dot product of one +/// of the first three rows with `[x, y, z, 1]`. +#[must_use] +pub(crate) fn transform_native_effect_point( + matrix: &[[f32; 4]; 4], + point: [f32; 3], +) -> Option<[f32; 3]> { + if !matrix.iter().flatten().all(|value| value.is_finite()) + || !point.iter().all(|value| value.is_finite()) + { + return None; + } + let result = std::array::from_fn(|row| { + matrix[row][0] * point[0] + + matrix[row][1] * point[1] + + matrix[row][2] * point[2] + + matrix[row][3] + }); + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +/// Transforms the native canonical quad through the final helper matrix. +#[must_use] +pub(crate) fn transform_native_effect_quad(matrix: &[[f32; 4]; 4]) -> Option<[[f32; 3]; 4]> { + let vertices = EFFECT_QUAD_CORNERS.map(|corner| transform_native_effect_point(matrix, corner)); + if vertices.iter().any(Option::is_none) { + return None; + } + Some(vertices.map(Option::unwrap)) +} + +/// Applies the native `g_FastProc + 0x68` point operation. +/// +/// This is the native component-wise projection: subtract matrix translation, +/// dot the delta with each basis column, and divide by that column's squared +/// length. It is the `g_FastProc + 0x68` contract used by effect callers. +#[must_use] +pub(crate) fn native_effect_local_point( + matrix: &[[f32; 4]; 4], + point: [f32; 3], +) -> Option<[f32; 3]> { + if !matrix.iter().flatten().all(|value| value.is_finite()) + || !point.iter().all(|value| value.is_finite()) + { + return None; + } + let delta = [ + point[0] - matrix[0][3], + point[1] - matrix[1][3], + point[2] - matrix[2][3], + ]; + let mut result = [0.0; 3]; + for axis in 0..3 { + let basis = [matrix[0][axis], matrix[1][axis], matrix[2][axis]]; + let squared_length = basis + .iter() + .map(|component| component * component) + .sum::<f32>(); + if !squared_length.is_finite() || squared_length == 0.0 { + return None; + } + result[axis] = + (basis[0] * delta[0] + basis[1] * delta[1] + basis[2] * delta[2]) / squared_length; + } + result + .iter() + .all(|value| value.is_finite()) + .then_some(result) +} + +/// Composes a native FX local basis and translation with the effect parent. +/// +/// This matches the shared `93D0` finalization: scale the parent basis columns +/// by the animated scale, multiply by the local basis, then replace the +/// translation with the unscaled parent transform of the animated position. +#[must_use] +pub(crate) fn compose_native_effect_matrix( + world_from_effect: &[[f32; 4]; 4], + local_basis: &[[f32; 4]; 4], + position: [f32; 3], + scale: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !local_basis.iter().flatten().all(|value| value.is_finite()) + || !position + .iter() + .chain(scale.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let mut scaled_parent = *world_from_effect; + for row in 0..3 { + for column in 0..3 { + scaled_parent[row][column] *= scale[column]; + } + } + let mut result = multiply_affine(&scaled_parent, local_basis)?; + let world_position = transform_native_effect_point(world_from_effect, position)?; + for axis in 0..3 { + result[axis][3] = world_position[axis]; + } + result[3] = [0.0, 0.0, 0.0, 1.0]; + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn d110_builds_general_axis_basis_and_preserves_native_axis_length() { + let matrix = native_d110_basis([1.0, 2.0, 2.0], [3.0, 4.0, 5.0]).unwrap(); + let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt(); + assert_eq!(matrix[0], [-2.0 * inv_sqrt_5, -2.0 * inv_sqrt_5, 1.0, 3.0]); + assert_eq!(matrix[1], [inv_sqrt_5, -4.0 * inv_sqrt_5, 2.0, 4.0]); + assert_eq!(matrix[2], [0.0, 5.0 / 5.0_f32.sqrt(), 2.0, 5.0]); + } + + #[test] + fn d110_uses_native_x_then_y_degenerate_fallbacks() { + let x_fallback = native_d110_basis([0.0, 1.0, 0.0], [0.0; 3]).unwrap(); + assert_eq!(x_fallback[0][0..3], [1.0, 0.0, 0.0]); + assert_eq!(x_fallback[1][0..3], [0.0, 0.0, 1.0]); + + let y_fallback = native_d110_basis([1.0, 0.0, 0.0], [0.0; 3]).unwrap(); + assert_eq!(y_fallback[0][0..3], [0.0, 0.0, 1.0]); + assert_eq!(y_fallback[1][0..3], [1.0, 0.0, 0.0]); + } + + #[test] + fn native_point_operation_projects_each_translated_basis_column() { + // Deliberately non-symmetric, translated, and non-orthogonal. The + // native helper projects independently onto each column and divides + // by that column's squared length; this is not a full matrix inverse. + let matrix = [ + [2.0, 3.0, 5.0, 7.0], + [11.0, 13.0, 17.0, 19.0], + [23.0, 29.0, 31.0, 37.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + native_effect_local_point(&matrix, [41.0, 43.0, 47.0]), + Some([562.0 / 654.0, 704.0 / 1019.0, 888.0 / 1275.0]) + ); + + let nonuniform_translated = [ + [2.0, 0.0, 0.0, 1.0], + [0.0, 3.0, 0.0, 2.0], + [0.0, 0.0, 4.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + native_effect_local_point(&nonuniform_translated, [6.0, 8.0, 8.0]), + Some([2.5, 2.0, 1.25]) + ); + + let tiny_basis = [ + [0.0001, 0.0, 0.0, 0.0], + [0.0, 2.0, 0.0, 0.0], + [0.0, 0.0, 3.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + native_effect_local_point(&tiny_basis, [0.0002, 2.0, 3.0]), + Some([2.0, 1.0, 1.0]) + ); + } + + #[test] + fn native_quad_uses_canonical_corner_order_and_affine_rows() { + let matrix = [ + [2.0, 3.0, 5.0, 7.0], + [11.0, 13.0, 17.0, 19.0], + [23.0, 29.0, 31.0, 37.0], + [0.0, 0.0, 0.0, 1.0], + ]; + assert_eq!( + transform_native_effect_quad(&matrix), + Some([ + [7.5, 20.0, 40.0], + [4.5, 7.0, 11.0], + [6.5, 18.0, 34.0], + [9.5, 31.0, 63.0], + ]) + ); + assert_eq!( + EFFECT_QUAD_UV, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]] + ); + } + + #[test] + fn affine_helpers_reject_non_finite_inputs_and_results() { + let mut matrix = [[0.0; 4]; 4]; + matrix[3][3] = 1.0; + assert_eq!( + transform_native_effect_point(&matrix, [f32::NAN, 0.0, 0.0]), + None + ); + matrix[0][0] = f32::INFINITY; + assert_eq!(transform_native_effect_quad(&matrix), None); + } + + #[test] + fn f_gener_ball_snapshot_emits_generated_op9_meshes_and_an_op4_quad() { + // Provenance: decoded `effects.rlb/f_gener_ball` snapshot in + // target/research-playground/generator-orb/fx-bodies.txt. The source + // archive SHA-256 was + // 3DAF49A8A4DA2FBC181F86822E125E47EB18F431BB5DF10EC53AC2EDC29C0A89. + // Keep only the known command fields here; no licensed archive bytes + // or unneeded Op1 semantics are copied into the test. + let commands = f_gener_ball_snapshot_commands(); + assert_eq!(commands.len(), 4); + assert_eq!( + commands + .iter() + .map(|command| command.opcode) + .collect::<Vec<_>>(), + [FxOpcode::Op9, FxOpcode::Op9, FxOpcode::Op4, FxOpcode::Op1] + ); + + // The decoded FX header carries a uniform 0.75 parent scale. That + // scale belongs to the input matrix, not the command body. + let parent = uniform_scale_matrix(0.75); + let camera = [0.0, 0.0, 500.0 / 0.75]; + let op9_meshes = commands[..2] + .iter() + .enumerate() + .map(|(index, command)| { + evaluate_f_gener_ball_mesh(command, index, &parent, 0.875, 0.3, camera, 0) + .expect("native Op9 mode emits a generated hemisphere") + }) + .collect::<Vec<_>>(); + assert_eq!(op9_meshes.len(), 2); + assert_eq!(op9_meshes[0].command_index, 0); + assert_eq!(op9_meshes[1].command_index, 1); + + for frame in &op9_meshes { + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "laser_y_hit".to_owned(), + } + ); + assert_eq!(frame.normalized_time, 0.5); + assert!((frame.color[3] - 0.5).abs() < 1e-6); + assert!((frame.material_phase - 0.3).abs() < 1e-6); + assert!(frame.active); + assert_eq!(frame.native_flag_base, 3); + assert_eq!(frame.native_category, 0); + assert_eq!(frame.vertices.len(), 88); + assert_eq!(frame.uv.len(), 88); + assert_eq!(frame.indices.len(), 120); + } + assert_ne!(op9_meshes[0].vertices, op9_meshes[1].vertices); + assert!(evaluate_f_gener_ball(&commands[0], 0, &parent, 0.875, 0.3, camera).is_none()); + + let frame = evaluate_f_gener_ball(&commands[2], 2, &parent, 0.875, 0.3, camera) + .expect("Op4 remains a billboard quad"); + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "glow_y".to_owned(), + } + ); + // FastProc projects through the parent basis and divides by its + // squared length, so the uniform 0.75 parent scale maps the adjusted + // world camera to local distance 500 / (0.75 * 0.75) before Op4 rescales time. + assert!((frame.normalized_time - 8.0 / 9.0).abs() < 1e-6); + assert!((frame.color[3] - 17.0 / 18.0).abs() < 1e-6); + assert!((frame.material_phase - 0.7).abs() < 1e-6); + assert!(frame.active); + assert_vec3_close(frame.vertices[0], [-3.008_333_4, 3.008_333_4, 0.0]); + } + + #[test] + fn f_gener_ball_samples_position_scale_and_opacity_curves() { + let mut command = f_gener_ball_command(FxOpcode::Op9); + let body = Arc::make_mut(&mut command.raw_body); + put_vec3(body, 0x30, [2.0, 4.0, 8.0]); + put_vec3(body, 0x3C, [1.0, 2.0, 3.0]); + put_vec3(body, 0x48, [0.0, 1.0, 0.0]); + put_vec3(body, 0x54, [0.0, 0.0, 1.0]); + put_vec3(body, 0x60, [1.0, 2.0, 3.0]); + put_vec3(body, 0x6C, [3.0, 6.0, 11.0]); + // Native XYZ exponent order is body+0x80, +0x7C, +0x78. + put_vec3(body, 0x78, [3.0, 2.0, 1.0]); + put_f32(body, 0x10, 0.2); + put_f32(body, 0x14, 1.0); + put_f32(body, 0x18, 2.0); + + let identity = identity_matrix(); + let sample = evaluate_f_gener_ball_sample(&command, &identity, 0.875, 0.35, [0.0; 3]) + .expect("curve fields are valid"); + assert_eq!(sample.normalized_time, 0.5); + assert_vec3_close( + transform_native_effect_point(&sample.matrix, [0.0; 3]).unwrap(), + [1.0, 1.0, 1.0], + ); + for (column, expected_length) in [2.0, 2.5, 2.5].into_iter().enumerate() { + let basis = [ + sample.matrix[0][column], + sample.matrix[1][column], + sample.matrix[2][column], + ]; + assert!((vector_length(basis).unwrap() - expected_length).abs() < 1e-6); + } + assert!((sample.color[3] - 0.4).abs() < 1e-6); + } + + #[test] + fn f_gener_ball_op4_uses_local_camera_distance_and_billboard_basis() { + let command = f_gener_ball_snapshot_commands().remove(2); + let identity = identity_matrix(); + let frame = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, [0.0, 0.0, 500.0]) + .expect("supported Op4 fixture command"); + + assert_eq!(frame.command_index, 2); + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "glow_y".to_owned(), + } + ); + assert_eq!(frame.normalized_time, 0.5); + assert!((frame.material_phase - 0.7).abs() < 1e-6); + assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.75]); + assert!(frame.active); + let expected_extent = 4.6 * 0.5; + assert_eq!( + frame.vertices, + [ + [-expected_extent, expected_extent, 0.0], + [-expected_extent, -expected_extent, 0.0], + [expected_extent, -expected_extent, 0.0], + [expected_extent, expected_extent, 0.0], + ] + ); + + let mut far_camera = [0.0, 0.0, 1000.0]; + let capped = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, far_camera) + .expect("camera distance caps Op4 progress"); + assert_eq!(capped.normalized_time, 1.0); + far_camera[2] = 0.0; + assert!(evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, far_camera).is_none()); + + let nonuniform_parent = [ + [2.0, 0.0, 0.0, 1.0], + [0.0, 3.0, 0.0, 2.0], + [0.0, 0.0, 4.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let camera_world = [6.0, 8.0, 8.0]; + let camera_local = native_effect_local_point(&nonuniform_parent, camera_world).unwrap(); + let expected_time = 0.5 * vector_length(camera_local).unwrap() / 500.0; + let transformed = + evaluate_f_gener_ball(&command, 2, &nonuniform_parent, 0.875, 0.3, camera_world) + .expect("Op4 uses the native column-projection camera query"); + assert!((transformed.normalized_time - expected_time).abs() < 1e-6); + } + + #[test] + fn f_gener_ball_phase_uses_interval_age_before_op4_camera_rescaling() { + let mut command = f_gener_ball_command(FxOpcode::Op4); + let body = Arc::make_mut(&mut command.raw_body); + put_f32(body, 0x04, 0.5); + let identity = identity_matrix(); + + // The command interval maps effect phase 0.875 into age 0.5. Op4's + // 250/500 render-distance factor reduces track time to 0.25, while + // 105F0/108F0 has already computed MAT0 phase from the unscaled 0.5. + let frame = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, [0.0, 0.0, 250.0]) + .expect("valid Op4 command"); + assert_eq!(frame.normalized_time, 0.25); + assert_eq!(frame.material_phase, 0.75); + } + + #[test] + fn f_gener_ball_rejects_unverified_modes_and_non_finite_state() { + let mut command = f_gener_ball_command(FxOpcode::Op9); + let identity = identity_matrix(); + Arc::make_mut(&mut command.raw_body)[..4].copy_from_slice(&1u32.to_le_bytes()); + assert!( + evaluate_f_gener_ball_mesh(&command, 0, &identity, 0.875, 0.35, [0.0; 3], 0).is_none() + ); + + let mut invalid_selector = f_gener_ball_command(FxOpcode::Op9); + put_u32(Arc::make_mut(&mut invalid_selector.raw_body), 0xC4, 3); + assert!(evaluate_f_gener_ball_mesh( + &invalid_selector, + 0, + &identity, + 0.875, + 0.35, + [0.0; 3], + 0 + ) + .is_none()); + + let command = f_gener_ball_command(FxOpcode::Op9); + let mut invalid_parent = identity; + invalid_parent[1][2] = f32::NAN; + assert!( + evaluate_f_gener_ball_mesh(&command, 0, &invalid_parent, 0.875, 0.35, [0.0; 3], 0) + .is_none() + ); + } + + #[test] + fn op9_c4_selector_maps_to_native_draw_flag_base() { + let identity = identity_matrix(); + for (selector, expected) in [(0, 3), (1, 5), (2, 6)] { + let mut command = f_gener_ball_command(FxOpcode::Op9); + put_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector); + let frame = evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 0, + ) + .expect("native Op9 C4 selector 0..=2"); + assert_eq!(frame.native_flag_base, expected); + assert_eq!(frame.native_category, 0); + let (sectors, rings) = match selector { + 0 => (8, 3), + 1 => (16, 6), + 2 => (24, 9), + _ => unreachable!(), + }; + let quads = sectors * (rings - 1); + assert_eq!(frame.vertices.len(), quads * 4 + sectors * 3); + assert_eq!(frame.indices.len(), quads * 6 + sectors * 3); + } + } + + #[test] + fn op9_mesh_uses_native_mode_category_counts_and_projected_uvs() { + let identity = identity_matrix(); + let command = f_gener_ball_command(FxOpcode::Op9); + let expected = [ + [(8_usize, 3_usize), (6, 3), (5, 2), (4, 1)], + [(16, 6), (12, 5), (10, 4), (8, 3)], + [(24, 9), (20, 8), (16, 6), (10, 4)], + ]; + + for (selector, mode) in [(0, 0), (1, 1), (2, 2)] { + let mut command = command.clone(); + put_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector); + for category in 0..4_u8 { + let mesh = evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + category, + ) + .expect("all native mode/category combinations have a mesh"); + let (sectors, rings) = expected[mode][usize::from(category)]; + let quads = sectors * (rings - 1); + assert_eq!(mesh.vertices.len(), quads * 4 + sectors * 3); + assert_eq!(mesh.uv.len(), mesh.vertices.len()); + assert_eq!(mesh.indices.len(), quads * 6 + sectors * 3); + assert!(mesh + .vertices + .iter() + .flatten() + .all(|value| value.is_finite())); + assert!(mesh.uv.iter().flatten().all(|value| value.is_finite())); + assert!(mesh + .indices + .iter() + .all(|index| usize::from(*index) < mesh.vertices.len())); + } + } + + let mut mode0_command = command.clone(); + put_u32(Arc::make_mut(&mut mode0_command.raw_body), 0xC4, 0); + put_vec3( + Arc::make_mut(&mut mode0_command.raw_body), + 0x48, + [0.0, 0.0, 1.0], + ); + put_vec3( + Arc::make_mut(&mut mode0_command.raw_body), + 0x54, + [0.0, 0.0, 1.0], + ); + let mesh = evaluate_f_gener_ball_mesh( + &mode0_command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 0, + ) + .expect("last selector remains valid"); + // Native triangle cap starts after all sector-major quads. Sector 0 + // is pole, next azimuth, current azimuth with XY-projected UVs. + let cap_start = (8 * (3 - 1)) * 4; + assert_vec3_close(mesh.vertices[cap_start], [0.0, 0.0, 0.4]); + assert_eq!(mesh.uv[cap_start], [0.5, 0.5]); + let next = mesh.vertices[cap_start + 1]; + assert!((next[0] - 0.10606602).abs() < 1e-6); + assert!((next[1] - 0.14142136).abs() < 1e-6); + assert!((next[2] - 0.34641016).abs() < 1e-6); + let cap_index_start = (8 * (3 - 1)) * 6; + assert_eq!(mesh.indices[cap_index_start], cap_start as u16); + assert_eq!(mesh.indices[cap_index_start + 1], cap_start as u16 + 1); + assert_eq!(mesh.indices[cap_index_start + 2], cap_start as u16 + 2); + + let mut static_uv_command = mode0_command.clone(); + put_u32(Arc::make_mut(&mut static_uv_command.raw_body), 0xC8, 1); + let static_uv_mesh = evaluate_f_gener_ball_mesh( + &static_uv_command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 0, + ) + .expect("nonzero native UV flag selects static UV arrays"); + assert_eq!(&static_uv_mesh.uv[..4], &EFFECT_QUAD_UV); + assert_eq!( + &static_uv_mesh.uv[cap_start..cap_start + 3], + &[[0.5, 0.99], [0.0, 0.0], [0.99, 0.0]] + ); + assert!(evaluate_f_gener_ball_mesh( + &command, + 0, + &identity, + 0.875, + 0.35, + [0.0, 0.0, 500.0], + 4, + ) + .is_none()); + } + + #[test] + fn op9_generated_mesh_triangles_keep_native_outward_winding() { + for (mode, flag_base) in [(0, 3), (1, 5), (2, 6)] { + for category in 0..4_u8 { + let (vertices, _, indices) = + generated_op9_hemisphere(flag_base, category, false).unwrap(); + assert!(!vertices.is_empty()); + assert_eq!(indices.len() % 3, 0); + for triangle in indices.chunks_exact(3) { + let a = vertices[usize::from(triangle[0])]; + let b = vertices[usize::from(triangle[1])]; + let c = vertices[usize::from(triangle[2])]; + let normal = cross(subtract_vector(b, a), subtract_vector(c, a)); + let centroid = [ + (a[0] + b[0] + c[0]) / 3.0, + (a[1] + b[1] + c[1]) / 3.0, + (a[2] + b[2] + c[2]) / 3.0, + ]; + let outward = + normal[0] * centroid[0] + normal[1] * centroid[1] + normal[2] * centroid[2]; + assert!( + outward > 0.0, + "mode {mode}, category {category}, triangle {triangle:?}" + ); + } + } + } + } + + #[test] + #[ignore = "requires the original GOG installation"] + fn original_gog_recharge_and_tree_plate_op9_modes_evaluate() { + let root = std::env::var_os("PARKAN_GOG_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + let archive_bytes: Arc<[u8]> = std::fs::read(root.join("effects.rlb")) + .expect("read effects.rlb") + .into(); + let archive = fparkan_nres::decode(archive_bytes, fparkan_nres::ReadProfile::Compatible) + .expect("decode effects.rlb"); + let identity = identity_matrix(); + + for name in ["f_recharge_r", "tree_plate_58"] { + let entry_id = archive + .find(name) + .unwrap_or_else(|| panic!("missing {name}")); + let document = crate::decode_fxid(Arc::from( + archive + .payload(entry_id) + .expect("FXID payload") + .to_vec() + .into_boxed_slice(), + )) + .unwrap_or_else(|error| panic!("decode {name}: {error}")); + let mut seen = false; + for (index, command) in document.commands().iter().enumerate() { + if command.opcode != FxOpcode::Op9 + || read_u32(&command.raw_body, 0) + .unwrap_or_else(|| panic!("{name} malformed Op9 mode")) + != 2 + { + continue; + } + let selector = read_u32(&command.raw_body, 0xC4) + .unwrap_or_else(|| panic!("{name} Op9 missing C4 selector")); + if !(0..=2).contains(&selector) { + continue; + } + let phase_start = read_f32(&command.raw_body, 0x1C) + .unwrap_or_else(|| panic!("{name} Op9 missing phase start")); + let phase_end = read_f32(&command.raw_body, 0x20) + .unwrap_or_else(|| panic!("{name} Op9 missing phase end")); + let frame = evaluate_f_gener_ball_mesh( + command, + index, + &identity, + (phase_start + phase_end) * 0.5, + 0.0, + [0.0, 0.0, 500.0], + 0, + ) + .unwrap_or_else(|| panic!("{name} Op9 selector {selector} did not evaluate")); + assert_eq!(frame.native_flag_base, [3, 5, 6][selector as usize]); + let (sectors, rings) = match selector { + 0 => (8, 3), + 1 => (16, 6), + 2 => (24, 9), + _ => unreachable!(), + }; + assert_eq!( + frame.vertices.len(), + sectors * (rings - 1) * 4 + sectors * 3 + ); + seen = true; + } + assert!(seen, "{name} has no supported Op9 mode"); + } + } + + fn f_gener_ball_snapshot_commands() -> Vec<FxCommand> { + let mut first_op9 = f_gener_ball_command(FxOpcode::Op9); + let mut second_op9 = f_gener_ball_command(FxOpcode::Op9); + put_vec3( + Arc::make_mut(&mut second_op9.raw_body), + 0x48, + [1.0, 0.0, 0.0], + ); + put_vec3( + Arc::make_mut(&mut second_op9.raw_body), + 0x54, + [1.0, 0.0, 0.0], + ); + // Both checked Op9 commands have opacity 0 -> 1 and opposite X axes. + put_f32(Arc::make_mut(&mut first_op9.raw_body), 0x10, 0.0); + put_f32(Arc::make_mut(&mut second_op9.raw_body), 0x10, 0.0); + + let mut op4 = f_gener_ball_command(FxOpcode::Op4); + put_f32(Arc::make_mut(&mut op4.raw_body), 0x10, 0.5); + + // The checked decoder confirms only that the fourth row is Op1. Its + // raw body is intentionally absent: this evaluator does not claim its + // layout or behavior. + let op1 = FxCommand { + word: 1, + opcode: FxOpcode::Op1, + native_flag: false, + raw_body: Arc::from([]), + resource_refs: Vec::new(), + }; + vec![first_op9, second_op9, op4, op1] + } + + fn f_gener_ball_command(opcode: FxOpcode) -> FxCommand { + let is_op9 = opcode == FxOpcode::Op9; + let mut body = vec![0u8; if is_op9 { 0xCC } else { 0xC8 }]; + let mode = if is_op9 { 2 } else { 0 }; + put_u32(&mut body, 0x00, mode); + put_f32(&mut body, 0x04, -1.0); + put_f32(&mut body, 0x08, if is_op9 { 11.0 } else { 9.0 }); + put_f32(&mut body, 0x0C, 1.0); + put_f32(&mut body, 0x10, 0.0); + put_f32(&mut body, 0x14, 1.0); + put_f32(&mut body, 0x18, 1.0); + put_f32(&mut body, 0x1C, 0.75); + put_f32(&mut body, 0x20, 1.0); + put_vec3(&mut body, 0x24, [0.0; 3]); + put_vec3(&mut body, 0x30, [0.0; 3]); + put_vec3(&mut body, 0x3C, [1.0; 3]); + let axis = if is_op9 { + [-1.0, 0.0, 0.0] + } else { + [1.0, 0.0, 0.0] + }; + put_vec3(&mut body, 0x48, axis); + put_vec3(&mut body, 0x54, axis); + let scale_start = if is_op9 { [0.3, 0.4, 0.4] } else { [0.2; 3] }; + let scale_end = if is_op9 { [0.3, 0.4, 0.4] } else { [9.0; 3] }; + put_vec3(&mut body, 0x60, scale_start); + put_vec3(&mut body, 0x6C, scale_end); + put_vec3(&mut body, 0x78, [1.0; 3]); + put_name(&mut body, 0x84, "material.lib"); + put_name( + &mut body, + 0xA4, + if is_op9 { "laser_y_hit" } else { "glow_y" }, + ); + if is_op9 { + put_u32(&mut body, 0xC4, 0); + } else { + put_f32(&mut body, 0xC4, 500.0); + } + FxCommand { + word: if is_op9 { 9 } else { 4 }, + opcode, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: Vec::new(), + } + } + + fn 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 (index, component) in value.into_iter().enumerate() { + put_f32(body, offset + index * 4, component); + } + } + + fn put_name(body: &mut [u8], offset: usize, value: &str) { + body[offset..offset + value.len()].copy_from_slice(value.as_bytes()); + } + + fn identity_matrix() -> [[f32; 4]; 4] { + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + } + + fn uniform_scale_matrix(scale: f32) -> [[f32; 4]; 4] { + [ + [scale, 0.0, 0.0, 0.0], + [0.0, scale, 0.0, 0.0], + [0.0, 0.0, scale, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + } + + fn assert_vec3_close(actual: [f32; 3], expected: [f32; 3]) { + for (actual, expected) in actual.into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}"); + } + } +} diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 2ef5b56..c08eec5 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -8,6 +8,7 @@ pub mod atmosphere; pub mod environment; +pub mod fx_visual; pub mod shadow; pub mod sky; diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs index da0b716..082d10c 100644 --- a/crates/fparkan-material/src/lib.rs +++ b/crates/fparkan-material/src/lib.rs @@ -717,6 +717,10 @@ fn interpolate_coefficients( mask: u32, fraction: f32, ) -> MaterialCoefficients { + // World3D 0x3030 receives source and destination wrappers one dword before + // each D3DMATERIAL7 body. Its +4/+14/+24/+34 offsets therefore select + // parameters 4..6, 0..2, 8..10, and 12..14; +0x20 selects parameter 3. + // The other alpha/extra fields, power, and page remain from the left phase. let left = left.coefficients(); let right = right.coefficients(); let mut output = left; @@ -1306,6 +1310,59 @@ mod tests { } #[test] + fn material_animation_interpolation_masks_match_native_rebased_offsets() { + let left = phase_with_parameters( + [ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, + ], + b"LEFT", + ); + let right = phase_with_parameters( + [ + 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, + 240, + ], + b"RIGHT", + ); + let rgb = |first: u8| [first, first + 10, first + 20].map(|value| f32::from(value) / 255.0); + let cases = [ + (0, rgb(10), rgb(50), rgb(90), rgb(130), 0.4), + (1, rgb(40), rgb(50), rgb(90), rgb(130), 0.4), + (2, rgb(10), rgb(80), rgb(90), rgb(130), 0.4), + (4, rgb(10), rgb(50), rgb(120), rgb(130), 0.4), + (8, rgb(10), rgb(50), rgb(90), rgb(160), 0.4), + (16, rgb(10), rgb(50), rgb(90), rgb(130), 0.7), + (0x1f, rgb(40), rgb(80), rgb(120), rgb(160), 0.7), + ]; + + for (mask, additive, directional, specular, extra, opacity) in cases { + let output = interpolate_coefficients(&left, &right, mask, 0.5); + for (actual, expected) in [ + (output.additive_rgb, additive), + (output.directional_rgb, directional), + (output.specular_rgb, specular), + (output.extra_rgb, extra), + ] { + for channel in 0..3 { + assert!( + (actual[channel] - expected[channel]).abs() < 1e-6, + "mask {mask:#x}, channel {channel}: got {}, expected {}", + actual[channel], + expected[channel] + ); + } + } + + assert!((output.opacity - opacity).abs() < 1e-6, "mask {mask:#x}"); + assert_eq!(output.directional_extra, 80.0 / 255.0); + assert_eq!(output.specular_extra, 120.0 / 255.0); + assert_eq!(output.extra_alpha, 160.0 / 255.0); + assert_eq!(output.power, 170); + assert_eq!(output.page_index, -76); + } + } + + #[test] fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() { let phases = vec![ phase_with_parameters([0; 18], b"A"), diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 639fc57..88bd325 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -28,6 +28,7 @@ const REQUIRED_TYPES: [u32; 9] = [ const AREAL_PREFIX_SIZE: usize = 56; const SLOT_HEADER_SIZE: usize = 0x8c; const SLOT_STRIDE: usize = 68; +const NODE38_STRIDE: usize = 38; const GRID_HIT_COUNT_BITS: u32 = 10; const GRID_POOL_OFFSET_MASK: u32 = (1 << 22) - 1; @@ -152,6 +153,129 @@ pub struct TerrainFace28 { pub raw: [u8; 28], } +/// Terrain detail level selected by one Node38 slot reference. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerrainLod { + /// Near terrain geometry. + Lod0, + /// Far terrain geometry. + Lod1, +} + +/// Node and detail level that own one source face. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerrainFaceLod { + /// Index into [`LandMeshDocument::faces`]. + pub face_index: usize, + /// Index into the Node38 stream. + pub node_index: usize, + /// Selected terrain detail level. + pub lod: TerrainLod, +} + +/// Error in the terrain Node38-to-face slot mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TerrainLodError { + /// Nonempty terrain has no Node38 records to select its slots. + MissingNodes { face_count: usize }, + /// Node stream size is not a whole number of 38-byte records. + InvalidNodeStreamSize { size: usize }, + /// Terrain uses a slot group beyond its two supported detail levels. + UnsupportedNodeSlot { + /// Node containing the unsupported slot. + node: usize, + /// Index in the 15-entry Node38 slot map. + slot_index: usize, + /// Raw terrain slot id. + slot: u16, + }, + /// A Node38 entry references a missing Slot68 record. + SlotOutOfRange { + /// Node containing the reference. + node: usize, + /// Selected detail level. + lod: TerrainLod, + /// Raw terrain slot id. + slot: u16, + /// Number of decoded Slot68 records. + slot_count: usize, + }, + /// A Slot68 triangle range exceeds the source face array. + FaceRangeOutOfBounds { + /// Node containing the slot. + node: usize, + /// Selected detail level. + lod: TerrainLod, + /// Raw terrain slot id. + slot: u16, + /// First source face. + start: usize, + /// Number of faces. + count: usize, + /// Number of source faces. + face_count: usize, + }, + /// A source face belongs to more than one terrain slot. + DuplicateFaceAssignment { face: usize }, + /// A source face is absent from every supported terrain slot. + MissingFaceAssignment { face: usize }, + /// A checked index calculation overflowed. + IntegerOverflow, +} + +impl std::fmt::Display for TerrainLodError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingNodes { face_count } => write!( + f, + "terrain has {face_count} faces but no Node38 records" + ), + Self::InvalidNodeStreamSize { size } => write!( + f, + "terrain Node38 stream has {size} bytes, not a multiple of {NODE38_STRIDE}" + ), + Self::UnsupportedNodeSlot { + node, + slot_index, + slot, + } => write!( + f, + "terrain Node38 {node} uses unsupported slot group {slot_index} ({slot})" + ), + Self::SlotOutOfRange { + node, + lod, + slot, + slot_count, + } => write!( + f, + "terrain Node38 {node} LOD {lod:?} references slot {slot} outside {slot_count} slots" + ), + Self::FaceRangeOutOfBounds { + node, + lod, + slot, + start, + count, + face_count, + } => write!( + f, + "terrain Node38 {node} LOD {lod:?} slot {slot} range {start}..{} exceeds {face_count} faces", + start.saturating_add(*count) + ), + Self::DuplicateFaceAssignment { face } => { + write!(f, "terrain face {face} belongs to more than one slot") + } + Self::MissingFaceAssignment { face } => { + write!(f, "terrain face {face} is not assigned to a supported slot") + } + Self::IntegerOverflow => write!(f, "terrain slot index overflow"), + } + } +} + +impl std::error::Error for TerrainLodError {} + impl TerrainFace28 { /// Decodes the proven two-table selector layout of [`Self::material_tag`]. #[must_use] @@ -273,6 +397,106 @@ pub struct LandMeshDocument { } impl LandMeshDocument { + /// Resolves each source face to the Node38 and terrain LOD that own it. + /// + /// The supported terrain layout uses slot-map entries 0 and 1 for LOD0 + /// and LOD1. The other 13 entries must be empty. Every source face must be + /// covered exactly once; empty terrain may omit Node38 records. + /// + /// # Errors + /// + /// Returns [`TerrainLodError`] for missing or unsupported slots, invalid + /// ranges, overlaps, or uncovered faces. + pub fn face_lod_assignments(&self) -> Result<Vec<TerrainFaceLod>, TerrainLodError> { + if self.faces.is_empty() && self.nodes_raw.is_empty() { + return Ok(Vec::new()); + } + if !self.nodes_raw.len().is_multiple_of(NODE38_STRIDE) { + return Err(TerrainLodError::InvalidNodeStreamSize { + size: self.nodes_raw.len(), + }); + } + if self.nodes_raw.is_empty() { + return Err(TerrainLodError::MissingNodes { + face_count: self.faces.len(), + }); + } + + let mut assignments = vec![None; self.faces.len()]; + for (node_index, node) in self.nodes_raw.chunks_exact(NODE38_STRIDE).enumerate() { + for slot_index in 2..15 { + let offset = 8 + slot_index * 2; + let slot = u16::from_le_bytes([node[offset], node[offset + 1]]); + if slot != u16::MAX { + return Err(TerrainLodError::UnsupportedNodeSlot { + node: node_index, + slot_index, + slot, + }); + } + } + + for (lod_index, lod) in [TerrainLod::Lod0, TerrainLod::Lod1].into_iter().enumerate() { + let offset = 8 + lod_index * 2; + let slot = u16::from_le_bytes([node[offset], node[offset + 1]]); + if slot == u16::MAX { + continue; + } + let slot_index = usize::from(slot); + let raw = self.slots.slots_raw.get(slot_index).ok_or( + TerrainLodError::SlotOutOfRange { + node: node_index, + lod, + slot, + slot_count: self.slots.slots_raw.len(), + }, + )?; + let start = usize::from(u16::from_le_bytes([raw[0], raw[1]])); + let count = usize::from(u16::from_le_bytes([raw[2], raw[3]])); + let end = start + .checked_add(count) + .ok_or(TerrainLodError::IntegerOverflow)?; + if end > self.faces.len() { + return Err(TerrainLodError::FaceRangeOutOfBounds { + node: node_index, + lod, + slot, + start, + count, + face_count: self.faces.len(), + }); + } + for face_index in start..end { + let assignment = assignments.get_mut(face_index).ok_or( + TerrainLodError::FaceRangeOutOfBounds { + node: node_index, + lod, + slot, + start, + count, + face_count: self.faces.len(), + }, + )?; + if assignment.is_some() { + return Err(TerrainLodError::DuplicateFaceAssignment { face: face_index }); + } + *assignment = Some(TerrainFaceLod { + face_index, + node_index, + lod, + }); + } + } + } + assignments + .into_iter() + .enumerate() + .map(|(face_index, assignment)| { + assignment.ok_or(TerrainLodError::MissingFaceAssignment { face: face_index }) + }) + .collect() + } + /// Resolves the type-11 material pairs addressed by one 68-byte slot. /// /// Returns `None` when the slot is absent or its pair range does not fit @@ -1564,6 +1788,80 @@ mod tests { } #[test] + fn terrain_face_lod_assignments_keep_interleaved_source_order_and_reject_missing_nodes() { + let mut nodes_raw = vec![u8::MAX; 2 * NODE38_STRIDE]; + for (node_index, [lod0_slot, lod1_slot]) in [[0_u16, 1_u16], [2, 3]].into_iter().enumerate() + { + let offset = node_index * NODE38_STRIDE + 8; + nodes_raw[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes()); + nodes_raw[offset + 2..offset + 4].copy_from_slice(&lod1_slot.to_le_bytes()); + } + let mut slots_raw = vec![[0_u8; SLOT_STRIDE]; 4]; + for (raw, start) in slots_raw.iter_mut().zip([0_u16, 1, 2, 3]) { + raw[..2].copy_from_slice(&start.to_le_bytes()); + raw[2..4].copy_from_slice(&1_u16.to_le_bytes()); + } + let mesh = LandMeshDocument { + streams: Vec::new(), + nodes_raw, + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw, + }, + positions: Vec::new(), + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: (0..4).map(|_| terrain_face([0, 1, 2])).collect(), + }; + + let assignments = mesh.face_lod_assignments().expect("complete slot mapping"); + assert_eq!( + assignments, + vec![ + TerrainFaceLod { + face_index: 0, + node_index: 0, + lod: TerrainLod::Lod0, + }, + TerrainFaceLod { + face_index: 1, + node_index: 0, + lod: TerrainLod::Lod1, + }, + TerrainFaceLod { + face_index: 2, + node_index: 1, + lod: TerrainLod::Lod0, + }, + TerrainFaceLod { + face_index: 3, + node_index: 1, + lod: TerrainLod::Lod1, + }, + ] + ); + + let mut missing_nodes = mesh.clone(); + missing_nodes.nodes_raw.clear(); + assert_eq!( + missing_nodes.face_lod_assignments(), + Err(TerrainLodError::MissingNodes { face_count: 4 }) + ); + missing_nodes.faces.clear(); + assert_eq!(missing_nodes.face_lod_assignments(), Ok(Vec::new())); + + let mut overlapping = mesh; + overlapping.slots.slots_raw[3][..2].copy_from_slice(&2_u16.to_le_bytes()); + assert_eq!( + overlapping.face_lod_assignments(), + Err(TerrainLodError::DuplicateFaceAssignment { face: 2 }) + ); + } + + #[test] fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() { let nres = decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres"); @@ -1836,6 +2134,42 @@ Generator 1 .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); let document = decode_land_msh(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let assignments = document + .face_lod_assignments() + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + assert_eq!(assignments.len(), document.faces.len(), "{corpus} {path:?}"); + for (face_index, assignment) in assignments.iter().enumerate() { + assert_eq!(assignment.face_index, face_index, "{corpus} {path:?}"); + } + let lod0_faces = assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .count(); + let lod1_faces = assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod1) + .count(); + assert_eq!( + lod0_faces + lod1_faces, + document.faces.len(), + "{corpus} {path:?}" + ); + if corpus == "IS" { + let map_name = path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase); + let expected_lod0 = match map_name.as_deref() { + Some("automap") => Some(1_912), + Some("prol") => Some(5_868), + Some("tut_1") => Some(5_046), + _ => None, + }; + if let Some(expected) = expected_lod0 { + assert_eq!(lod0_faces, expected, "{corpus} {path:?} LOD0 face count"); + } + } files += 1; vertices += document.positions.len(); faces += document.faces.len(); @@ -2127,6 +2461,18 @@ Generator 1 out } + fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 { + TerrainFace28 { + flags: FullSurfaceMask(0), + material_tag: 0, + aux_tag: 0, + vertices, + neighbors: [None; 3], + tail_raw: *b"TAILFACE", + raw: face(vertices, [None; 3]), + } + } + fn entry(type_id: u32, attr1: u32, attr3: u32, payload: &[u8]) -> TestEntry<'_> { TestEntry { type_id, diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index cd2a874..a014b61 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -9,7 +9,7 @@ use std::collections::VecDeque; /// Applications access this semantic terrain API through this terrain crate /// rather than taking a direct dependency on the binary-format parser. pub use fparkan_terrain_format::{ - FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection, + FullSurfaceMask, TerrainLod, TerrainLodError, TerrainMaterialLayers, TerrainMaterialSelection, }; /// Terrain world. @@ -91,6 +91,8 @@ pub enum TerrainError { /// Position count. position_count: usize, }, + /// Terrain Node38/Slot68 records do not assign every face to one LOD. + InvalidLodMapping(TerrainLodError), } impl std::fmt::Display for TerrainError { @@ -117,11 +119,23 @@ impl std::fmt::Display for TerrainError { f, "terrain face {face} references vertex {vertex} outside {position_count} positions" ), + Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"), } } } -impl std::error::Error for TerrainError {} +impl std::error::Error for TerrainError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidLodMapping(error) => Some(error), + Self::Unsupported + | Self::TooManyAreals { .. } + | Self::InvalidGridReference { .. } + | Self::InvalidArealReference { .. } + | Self::InvalidSurfaceVertex { .. } => None, + } + } +} /// Surface query. pub trait SurfaceQuery { @@ -247,8 +261,8 @@ impl TerrainWorld { /// /// # Errors /// - /// Returns [`TerrainError`] if a face cannot be represented by runtime - /// indexes. + /// Returns [`TerrainError`] if terrain LOD metadata or a face cannot be + /// represented by runtime indexes. pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> { Ok(Self { surfaces: build_surfaces(mesh)?, @@ -878,8 +892,19 @@ impl RuntimeGrid { } fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, TerrainError> { - let mut triangles = Vec::with_capacity(mesh.faces.len()); - for (face_index, face) in mesh.faces.iter().enumerate() { + let assignments = mesh + .face_lod_assignments() + .map_err(TerrainError::InvalidLodMapping)?; + let lod0_count = assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .count(); + let mut triangles = Vec::with_capacity(lod0_count); + for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) { + if assignment.lod != TerrainLod::Lod0 { + continue; + } + let face_index = assignment.face_index; let vertices = [ surface_vertex(mesh, face_index, face.vertices[0])?, surface_vertex(mesh, face_index, face.vertices[1])?, @@ -1243,7 +1268,7 @@ mod tests { let face0 = terrain_face(FullSurfaceMask(0x0000_0001), [0, 1, 2]); let face1 = terrain_face(FullSurfaceMask(0x0000_0002), [1, 3, 2]); - LandMeshDocument { + let mut mesh = LandMeshDocument { streams: Vec::<TerrainStream>::new(), nodes_raw: Vec::new(), slots: TerrainSlotTable { @@ -1262,7 +1287,14 @@ mod tests { aux14: Vec::new(), aux18: Vec::new(), faces: vec![face0, face1], - } + }; + let mut node = vec![u8::MAX; 38]; + node[8..10].copy_from_slice(&0_u16.to_le_bytes()); + let mut slot = [0_u8; 68]; + slot[2..4].copy_from_slice(&(mesh.faces.len() as u16).to_le_bytes()); + mesh.nodes_raw = node; + mesh.slots.slots_raw.push(slot); + mesh } fn terrain_face( |
