aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-fx/src/atmosphere.rs38
-rw-r--r--crates/fparkan-material/src/lib.rs57
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs346
-rw-r--r--crates/fparkan-terrain/src/lib.rs48
-rw-r--r--crates/fparkan-texm/src/lib.rs19
5 files changed, 489 insertions, 19 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-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(
diff --git a/crates/fparkan-texm/src/lib.rs b/crates/fparkan-texm/src/lib.rs
index 3578dd0..4b7ffaa 100644
--- a/crates/fparkan-texm/src/lib.rs
+++ b/crates/fparkan-texm/src/lib.rs
@@ -665,10 +665,10 @@ fn decode_mip_rgba8_internal(
continue;
}
let out = index.saturating_mul(4);
- rgba[out] = palette[palette_offset];
+ rgba[out] = palette[palette_offset + 2];
rgba[out + 1] = palette[palette_offset + 1];
- rgba[out + 2] = palette[palette_offset + 2];
- rgba[out + 3] = palette[palette_offset + 3];
+ rgba[out + 2] = palette[palette_offset];
+ rgba[out + 3] = 255;
}
}
DiskPixelFormat::Rgb565 => decode_words(level_data, pixel_count, &mut rgba, decode_rgb565),
@@ -996,6 +996,17 @@ mod tests {
}
#[test]
+ fn indexed_palette_decodes_bgrx_as_opaque_rgba() {
+ let document =
+ decode_texm(Arc::from(indexed_payload().into_boxed_slice())).expect("indexed document");
+
+ assert_eq!(
+ decode_mip_rgba8(&document, 0).expect("rgba").rgba8,
+ [30, 20, 10, 255]
+ );
+ }
+
+ #[test]
fn channel_expansion_boundary_values_are_stable() {
let document = decode_texm(Arc::from(
payload(2, 1, 565, &[&[0x00, 0x00, 0xFF, 0xFF]]).into_boxed_slice(),
@@ -1155,7 +1166,7 @@ mod tests {
fn indexed_payload() -> Vec<u8> {
let mut palette = [0_u8; 1024];
- palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
+ palette[4..8].copy_from_slice(&[10, 20, 30, 0]);
let mut out = header(1, 1, 1, 0);
out.extend_from_slice(&palette);
out.push(1);