Skip to main content

rgpre/import/model3d/
mod.rs

1/// Low-level parser for Redguard 3D/3DC binary model data.
2pub mod parser;
3pub mod subobjects;
4
5#[derive(serde::Serialize, Debug, Clone, PartialEq, Eq)]
6/// Recognized model format versions from 3D/3DC headers.
7pub enum ModelVersion {
8    V26,
9    V27,
10    V40,
11    V50,
12    Unknown,
13}
14
15#[derive(serde::Serialize, Debug, Clone)]
16/// Face material reference as either a palette index or texture/image pair.
17pub enum TextureData {
18    SolidColor(u8),
19    Texture { texture_id: u16, image_id: u8 },
20}
21
22#[derive(serde::Serialize, Debug, Clone)]
23/// Parsed 64-byte 3D model header.
24pub struct Model3DHeader {
25    pub version: [u8; 4],
26    pub num_vertices: u32,
27    pub num_faces: u32,
28    pub radius: u32,
29    pub num_frames: u32,
30    pub offset_frame_data: u32,
31    pub total_face_vertices: u32,
32    pub offset_section4: u32,
33    pub section4_count: u32,
34    #[allow(
35        clippy::pub_underscore_fields,
36        reason = "field name mirrors reverse-engineered binary layout"
37    )]
38    /// Always 0 in v4.0/v5.0; bulk-copied as part of the 64-byte header but never
39    /// individually read at runtime.
40    pub _unused_24: u32,
41    pub offset_normal_indices: u32,
42    pub offset_vertex_normals: u32,
43    pub offset_vertex_coords: u32,
44    pub offset_face_normals: u32,
45    pub total_face_vertices_dup: u32,
46    pub offset_face_data: u32,
47}
48
49#[derive(serde::Serialize, Debug, Clone)]
50/// One vertex reference and accumulated UV coordinates in 1/16-pixel units.
51/// Parsed coordinates sum at most 255 signed 16-bit deltas without wrapping.
52pub struct FaceVertex {
53    pub vertex_index: u32,
54    pub u: i32,
55    pub v: i32,
56}
57
58#[derive(serde::Serialize, Debug, Clone)]
59/// Decoded face record with vertices and material information.
60pub struct FaceData {
61    pub vertex_count: u8,
62    /// Stored face brightness; model preparation uses `(tex_hi * 4) & 255`.
63    pub tex_hi: u8,
64    pub texture_data: TextureData,
65    pub face_vertices: Vec<FaceVertex>,
66}
67
68impl FaceData {
69    /// Returns the encoded byte size for this face in the given model version.
70    #[must_use]
71    pub const fn size_in_bytes(&self, version: &ModelVersion) -> usize {
72        let texture_header_size = if matches!(version, ModelVersion::V40 | ModelVersion::V50) {
73            1 + 4 // tex_hi (u8) + texture_raw (u32)
74        } else {
75            1 + 2 // u1 (u8) + texture_data (u16)
76        };
77        let vertex_data_size = self.face_vertices.len() * (4 + 2 + 2);
78        1 + texture_header_size + 4 + vertex_data_size
79    }
80}
81
82#[derive(serde::Serialize, Debug, Clone, Copy)]
83/// Vertex position in model space.
84pub struct VertexCoord {
85    pub x: f32,
86    pub y: f32,
87    pub z: f32,
88}
89
90#[derive(serde::Serialize, Debug, Clone)]
91/// Face normal vector.
92pub struct FaceNormal {
93    pub x: f32,
94    pub y: f32,
95    pub z: f32,
96}
97
98#[derive(serde::Serialize, Debug, Clone)]
99/// Vertex normal vector.
100pub struct VertexNormal {
101    pub x: f32,
102    pub y: f32,
103    pub z: f32,
104}
105
106#[derive(serde::Serialize, Debug, Clone, Copy, PartialEq, Eq)]
107/// Frame encoding used by frame-table entries.
108pub enum FrameType {
109    Static3D,
110    AnimatedI16,
111    /// Signed i16 deltas in whole model units (frame type 0x12).
112    AnimatedI16Unscaled,
113    AnimatedI32,
114    /// Software deltas add to a reference frame after a raw fixed-point shift.
115    SoftwareI16 {
116        base_frame: u16,
117        shift: u8,
118    },
119    Static3DC,
120    Unknown(u32),
121}
122
123#[derive(serde::Serialize, Debug, Clone)]
124/// Parsed frame table entry from animated model data.
125pub struct FrameDataEntry {
126    pub vertex_offset: u32,
127    pub normal_offset: u32,
128    pub reserved: u32,
129    pub frame_type: FrameType,
130}
131
132/// Absolute per-frame vertex positions in model units, reconstructed from frame-zero deltas.
133#[derive(serde::Serialize, Debug, Clone)]
134pub struct FrameVertexData {
135    pub coords: Vec<VertexCoord>,
136}
137
138/// Parsed per-frame face normals.
139#[derive(serde::Serialize, Debug, Clone)]
140pub struct FrameNormalData {
141    pub face_normals: Vec<FaceNormal>,
142}
143
144/// Parsed 3D/3DC model including geometry and frame metadata.
145#[derive(serde::Serialize, Debug, Clone)]
146pub struct Model3DFile {
147    pub header: Model3DHeader,
148    pub version: ModelVersion,
149    pub frame_data: Vec<FrameDataEntry>,
150    pub face_data: Vec<FaceData>,
151    pub vertex_coords: Vec<VertexCoord>,
152    pub face_normals: Vec<FaceNormal>,
153    pub normal_indices: Vec<u32>,
154    pub vertex_normals: Vec<VertexNormal>,
155    /// Per-frame vertex positions. Empty if no animation frames.
156    pub frame_vertex_data: Vec<FrameVertexData>,
157    /// Per-frame face normals. Empty if no animation frames.
158    pub frame_normal_data: Vec<FrameNormalData>,
159}
160
161impl Model3DHeader {
162    /// Returns the header version bytes as a trimmed UTF-8 string.
163    #[must_use]
164    pub fn version_string(&self) -> String {
165        String::from_utf8_lossy(&self.version)
166            .trim_matches('\0')
167            .to_string()
168    }
169
170    /// Maps the header version string to a known `ModelVersion` variant.
171    #[must_use]
172    pub fn parse_version(&self) -> ModelVersion {
173        let version_str = self.version_string();
174        match version_str.as_str() {
175            "v2.6" => ModelVersion::V26,
176            "v2.7" => ModelVersion::V27,
177            "v4.0" => ModelVersion::V40,
178            "v5.0" => ModelVersion::V50,
179            _ => ModelVersion::Unknown,
180        }
181    }
182
183    /// Returns `true` for v2.6 and v2.7 layout variants.
184    #[must_use]
185    pub fn is_v27_or_earlier(&self) -> bool {
186        matches!(self.parse_version(), ModelVersion::V26 | ModelVersion::V27)
187    }
188
189    /// Returns `true` for v4.0 and v5.0 layout variants.
190    #[must_use]
191    pub fn is_v40_or_later(&self) -> bool {
192        matches!(self.parse_version(), ModelVersion::V40 | ModelVersion::V50)
193    }
194}
195
196impl Model3DFile {
197    /// Counts vertices referenced by all parsed faces.
198    #[must_use]
199    pub fn total_face_vertices(&self) -> usize {
200        self.face_data
201            .iter()
202            .map(|face| usize::from(face.vertex_count))
203            .sum()
204    }
205
206    /// Computes an axis-aligned bounding box from parsed vertex positions.
207    #[must_use]
208    pub fn bounding_box(&self) -> Option<(VertexCoord, VertexCoord)> {
209        if self.vertex_coords.is_empty() {
210            return None;
211        }
212
213        let mut min = VertexCoord {
214            x: f32::INFINITY,
215            y: f32::INFINITY,
216            z: f32::INFINITY,
217        };
218        let mut max = VertexCoord {
219            x: f32::NEG_INFINITY,
220            y: f32::NEG_INFINITY,
221            z: f32::NEG_INFINITY,
222        };
223
224        for vertex in &self.vertex_coords {
225            min.x = min.x.min(vertex.x);
226            min.y = min.y.min(vertex.y);
227            min.z = min.z.min(vertex.z);
228            max.x = max.x.max(vertex.x);
229            max.y = max.y.max(vertex.y);
230            max.z = max.z.max(vertex.z);
231        }
232
233        Some((min, max))
234    }
235}
236
237use crate::{Result, error::Error};
238use log::warn;
239
240fn parse_data_internal(input: &[u8], kind: &str) -> Result<Model3DFile> {
241    match parser::parse_3d_file(input) {
242        Ok((remaining, model)) => {
243            if !remaining.is_empty() {
244                warn!("{} bytes remaining in {} data", remaining.len(), kind);
245            }
246            Ok(model)
247        }
248        Err(e)
249            if (input.starts_with(b"v2.6") || input.starts_with(b"v2.7")) && input.len() >= 64 =>
250        {
251            Err(Error::Parse(format!(
252                "Failed to parse software {kind}: invalid or incomplete animation directory, positions, normals or faces ({e:?})"
253            )))
254        }
255        Err(e) => Err(Error::Parse(format!("Failed to parse {kind}: {e:?}"))),
256    }
257}
258
259/// Parses a complete 3D/3DC model file from bytes.
260pub fn parse_3d_file(input: &[u8]) -> Result<Model3DFile> {
261    parse_data_internal(input, "3D model file")
262}
263
264impl Model3DFile {
265    /// Resolves a frame to absolute positions in parser model units.
266    ///
267    /// The parser has already reconstructed compressed frame positions.
268    /// Frame zero uses the parsed base geometry. Incomplete frames are rejected.
269    /// The returned model is a static snapshot with no animation frame table.
270    pub fn at_frame(&self, frame: usize) -> crate::Result<Self> {
271        use crate::error::Error;
272        let invalid = |message: &str| Error::Conversion(format!("model frame {frame}: {message}"));
273        if frame >= (self.header.num_frames as usize).max(1) {
274            return Err(invalid("outside frame table"));
275        }
276        let mut posed = self.clone();
277        posed.header.num_frames = 1;
278        posed.frame_data.clear();
279        posed.frame_vertex_data.clear();
280        posed.frame_normal_data.clear();
281        if frame == 0 {
282            return Ok(posed);
283        }
284        let encoding = self
285            .frame_data
286            .first()
287            .ok_or_else(|| invalid("missing encoding"))?
288            .frame_type;
289        let positions = &self
290            .frame_vertex_data
291            .get(frame)
292            .ok_or_else(|| invalid("missing positions"))?
293            .coords;
294        let normals = &self
295            .frame_normal_data
296            .get(frame)
297            .ok_or_else(|| invalid("missing normals"))?
298            .face_normals;
299        if positions.len() != self.vertex_coords.len() || normals.len() != self.face_data.len() {
300            return Err(invalid("incomplete geometry"));
301        }
302        posed.vertex_coords = match encoding {
303            FrameType::AnimatedI16
304            | FrameType::AnimatedI16Unscaled
305            | FrameType::AnimatedI32
306            | FrameType::SoftwareI16 { .. } => positions.clone(),
307            FrameType::Static3D
308                if matches!(self.version, ModelVersion::V40 | ModelVersion::V50) =>
309            {
310                positions.clone()
311            }
312            _ => return Err(invalid("unsupported encoding")),
313        };
314        posed.face_normals = normals.clone();
315        Ok(posed)
316    }
317}
318
319/// Exports all decoded model fields and authored collision subsets for inspection.
320/// Positions retain parser model units; face UVs are accumulated sixteenths of a texel.
321/// Frame poses are absolute, as in `frame_vertex_data`; no compressed payload is included.
322#[must_use]
323pub fn export_model_metadata(
324    model: &Model3DFile,
325    subobjects: &[subobjects::Subobject],
326) -> serde_json::Value {
327    serde_json::json!({
328        "model": model,
329        "subobjects": subobjects,
330        "scene_subobjects": subobjects.iter().map(|subobject| serde_json::json!({
331            "center": crate::gltf::model_position(subobject.center.map(|value| value as f32 / 256.0)),
332            "radius": subobject.radius as f32 / 256.0 / crate::gltf::ENGINE_UNIT_SCALE,
333            "extent_factors": subobject.extents,
334            "faces": subobject.faces,
335        })).collect::<Vec<_>>(),
336        "uv_units": "sixteenths_of_a_texel",
337        "bounding_box": model.bounding_box(),
338    })
339}