Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_asset/
lib.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2#![cfg_attr(target_arch = "mips", feature(asm_experimental_arch))]
3//! Runtime parsers for PSoXide cooked-asset blobs.
4//!
5//! Pairs with `editor/crates/psxed`, the host-side tool that
6//! produces these files. Format structs live in `psxed-format`
7//! (shared by both sides so drift is impossible).
8//!
9//! Usage pattern:
10//!
11//! ```ignore
12//! // At compile time, embed the cooked blob into the MIPS binary.
13//! static TEAPOT: &[u8] = include_bytes!("assets/teapot.psxm");
14//!
15//! // At runtime, parse into a typed view. Zero-copy -- the view
16//! // just borrows slices into the original byte stream.
17//! let mesh = psx_asset::Mesh::from_bytes(TEAPOT).expect("cooked mesh");
18//! for tri_idx in 0..mesh.face_count() {
19//!     let (ia, ib, ic) = mesh.face(tri_idx);
20//!     let v0 = mesh.vertex(ia);
21//!     let v1 = mesh.vertex(ib);
22//!     let v2 = mesh.vertex(ic);
23//!     let (r, g, b) = mesh.face_color(tri_idx).unwrap_or((128, 128, 128));
24//!     // project + draw …
25//! }
26//! ```
27//!
28//! Design:
29//!
30//! - **Zero-copy**: `Mesh::from_bytes` borrows into the caller's
31//!   byte slice. No allocation, no memcpy; the static blob stays
32//!   where it was embedded.
33//! - **`no_std`-clean**: all parsing is manual LE decode, no
34//!   `std::io`.
35//! - **Bounds-checked**: every accessor validates its index
36//!   against the counts in the header. Malformed blobs produce
37//!   `None` rather than panics at integer level, so consumers
38//!   see errors at load time, not in the render loop.
39
40#![no_std]
41#![deny(unsafe_op_in_unsafe_fn)]
42#![warn(missing_docs)]
43
44pub mod hma1;
45pub mod hmd8;
46
47use psx_gte::math::{Vec3I16, Vec3I32};
48
49const MESH_VERSION_U8_INDICES: u16 = 1;
50const MESH_U8_INDEX_STRIDE: usize = 3;
51const MESH_U16_INDEX_STRIDE: usize = 6;
52
53/// Shared topology helpers for cooked world quads and wall shapes.
54pub mod world_topology {
55    pub use psxed_format::world::topology::{
56        horizontal_triangle_at_local, split_triangle, split_triangles, triangle_contains_corner,
57        wall_shape_for_dropped_corner, wall_shape_triangle, wall_shape_triangle_corners,
58        SplitTriangles, TriangleCorners, HORIZONTAL_NE_SW_TRIANGLES, HORIZONTAL_NW_SE_TRIANGLES,
59        SPLIT_ONE_THREE_TRIANGLES, SPLIT_ZERO_TWO_TRIANGLES, WHOLE_QUAD_TRIANGLE_INDEX,
60    };
61}
62
63/// World split id from north-west to south-east.
64pub const WORLD_SPLIT_NORTH_WEST_SOUTH_EAST: u8 = psxed_format::world::split::NORTH_WEST_SOUTH_EAST;
65
66/// World split id from north-east to south-west.
67pub const WORLD_SPLIT_NORTH_EAST_SOUTH_WEST: u8 = psxed_format::world::split::NORTH_EAST_SOUTH_WEST;
68
69/// Full four-corner world-wall shape.
70pub const WORLD_WALL_SHAPE_QUAD: u16 = psxed_format::world::wall_shape::QUAD;
71
72/// World-wall shape with the bottom-left corner removed.
73pub const WORLD_WALL_SHAPE_DROP_BOTTOM_LEFT: u16 =
74    psxed_format::world::wall_shape::DROP_BOTTOM_LEFT;
75
76/// World-wall shape with the bottom-right corner removed.
77pub const WORLD_WALL_SHAPE_DROP_BOTTOM_RIGHT: u16 =
78    psxed_format::world::wall_shape::DROP_BOTTOM_RIGHT;
79
80/// World-wall shape with the top-right corner removed.
81pub const WORLD_WALL_SHAPE_DROP_TOP_RIGHT: u16 = psxed_format::world::wall_shape::DROP_TOP_RIGHT;
82
83/// World-wall shape with the top-left corner removed.
84pub const WORLD_WALL_SHAPE_DROP_TOP_LEFT: u16 = psxed_format::world::wall_shape::DROP_TOP_LEFT;
85
86/// Errors `Mesh::from_bytes` can return for a malformed blob.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ParseError {
89    /// Blob shorter than the minimum header sizes.
90    Truncated,
91    /// `AssetHeader::magic` doesn't match the expected asset kind.
92    WrongMagic,
93    /// Format version newer than this parser supports.
94    UnsupportedVersion(u16),
95    /// Declared payload_len disagrees with the actual byte length.
96    InvalidPayloadLen {
97        /// Payload length declared in the common asset header.
98        declared: u32,
99        /// Actual payload byte count after the common asset header.
100        actual: usize,
101    },
102    /// Vertex or face table wouldn't fit in the remaining payload.
103    TableOverflow,
104    /// World-grid header fields are inconsistent.
105    InvalidWorldLayout,
106    /// Model header/table fields are inconsistent.
107    InvalidModelLayout,
108    /// Animation header/table fields are inconsistent.
109    InvalidAnimationLayout,
110    /// Audio header/table fields are inconsistent.
111    InvalidAudioLayout,
112}
113
114/// A parsed 3D mesh backed by slices into the caller's cooked blob.
115///
116/// Cheap to construct (just bounds-checks the header + computes
117/// sub-slice offsets). Cheap to pass around -- table `&[u8]` slices,
118/// an index stride, a flags `u16`, and the counts.
119#[derive(Copy, Clone, Debug)]
120pub struct Mesh<'a> {
121    verts: &'a [u8],
122    indices: &'a [u8],
123    face_colors: Option<&'a [u8]>,
124    vertex_normals: Option<&'a [u8]>,
125    index_stride: u8,
126    vert_count: u16,
127    face_count: u16,
128    flags: u16,
129}
130
131impl<'a> Mesh<'a> {
132    /// Parse a cooked `.psxm` blob. Returns a `Mesh` view that
133    /// borrows into `bytes`.
134    pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, ParseError> {
135        // AssetHeader.
136        if bytes.len() < psxed_format::AssetHeader::SIZE {
137            return Err(ParseError::Truncated);
138        }
139        let magic: [u8; 4] = [bytes[0], bytes[1], bytes[2], bytes[3]];
140        if magic != psxed_format::mesh::MAGIC {
141            return Err(ParseError::WrongMagic);
142        }
143        let version = u16::from_le_bytes([bytes[4], bytes[5]]);
144        let index_stride = match version {
145            MESH_VERSION_U8_INDICES => MESH_U8_INDEX_STRIDE,
146            psxed_format::mesh::VERSION => MESH_U16_INDEX_STRIDE,
147            _ => return Err(ParseError::UnsupportedVersion(version)),
148        };
149        let flags = u16::from_le_bytes([bytes[6], bytes[7]]);
150        let payload_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
151        let payload_start = psxed_format::AssetHeader::SIZE;
152        let actual_payload = bytes.len().saturating_sub(payload_start);
153        if (payload_len as usize) != actual_payload {
154            return Err(ParseError::InvalidPayloadLen {
155                declared: payload_len,
156                actual: actual_payload,
157            });
158        }
159
160        // MeshHeader.
161        if actual_payload < psxed_format::mesh::MeshHeader::SIZE {
162            return Err(ParseError::Truncated);
163        }
164        let mh = &bytes[payload_start..];
165        let vert_count = u16::from_le_bytes([mh[0], mh[1]]);
166        let face_count = u16::from_le_bytes([mh[2], mh[3]]);
167        // Skip _reserved (4 bytes).
168
169        // Slice the vertex + index + optional colour tables out.
170        let mut off = payload_start + psxed_format::mesh::MeshHeader::SIZE;
171        let vert_bytes = (vert_count as usize) * 6;
172        if off + vert_bytes > bytes.len() {
173            return Err(ParseError::TableOverflow);
174        }
175        let verts = &bytes[off..off + vert_bytes];
176        off += vert_bytes;
177
178        let index_bytes = (face_count as usize) * index_stride;
179        if off + index_bytes > bytes.len() {
180            return Err(ParseError::TableOverflow);
181        }
182        let indices = &bytes[off..off + index_bytes];
183        off += index_bytes;
184
185        let face_colors = if flags & psxed_format::mesh::flags::HAS_FACE_COLORS != 0 {
186            let color_bytes = (face_count as usize) * 3;
187            if off + color_bytes > bytes.len() {
188                return Err(ParseError::TableOverflow);
189            }
190            let slice = &bytes[off..off + color_bytes];
191            off += color_bytes;
192            Some(slice)
193        } else {
194            None
195        };
196
197        let vertex_normals = if flags & psxed_format::mesh::flags::HAS_NORMALS != 0 {
198            let normal_bytes = (vert_count as usize) * 6;
199            if off + normal_bytes > bytes.len() {
200                return Err(ParseError::TableOverflow);
201            }
202            let slice = &bytes[off..off + normal_bytes];
203            Some(slice)
204        } else {
205            None
206        };
207
208        Ok(Self {
209            verts,
210            indices,
211            face_colors,
212            vertex_normals,
213            index_stride: index_stride as u8,
214            vert_count,
215            face_count,
216            flags,
217        })
218    }
219
220    /// Vertex count.
221    #[inline]
222    pub fn vertex_count(&self) -> u16 {
223        self.vert_count
224    }
225
226    /// Renamed to [`Self::vertex_count`].
227    #[deprecated(note = "renamed to `vertex_count`")]
228    #[inline(always)]
229    pub fn vert_count(&self) -> u16 {
230        self.vertex_count()
231    }
232
233    /// Triangle count.
234    #[inline]
235    pub fn face_count(&self) -> u16 {
236        self.face_count
237    }
238
239    /// Mesh feature flags (see [`psxed_format::mesh::flags`]).
240    #[inline]
241    pub fn flags(&self) -> u16 {
242        self.flags
243    }
244
245    /// Does the blob carry a face-colour table?
246    #[inline]
247    pub fn has_face_colors(&self) -> bool {
248        self.flags & psxed_format::mesh::flags::HAS_FACE_COLORS != 0
249    }
250
251    /// Does the blob carry per-vertex normals? Required for any
252    /// GTE- or CPU-lit rendering path.
253    #[inline]
254    pub fn has_normals(&self) -> bool {
255        self.flags & psxed_format::mesh::flags::HAS_NORMALS != 0
256    }
257
258    /// Decode vertex `i` as a Q3.12 [`Vec3I16`]. Returns
259    /// [`Vec3I16::ZERO`] if the index is out of range -- keeps
260    /// the render path branch-free, callers who care can
261    /// check against [`Self::vertex_count`] first.
262    #[inline]
263    pub fn vertex(&self, i: u16) -> Vec3I16 {
264        let idx = i as usize;
265        if idx >= self.vert_count as usize {
266            return Vec3I16::ZERO;
267        }
268        let base = idx * 6;
269        let x = i16::from_le_bytes([self.verts[base], self.verts[base + 1]]);
270        let y = i16::from_le_bytes([self.verts[base + 2], self.verts[base + 3]]);
271        let z = i16::from_le_bytes([self.verts[base + 4], self.verts[base + 5]]);
272        Vec3I16::new(x, y, z)
273    }
274
275    /// Triangle `i`'s three vertex indices. Returns `(0, 0, 0)`
276    /// for an out-of-range index.
277    #[inline]
278    pub fn face(&self, i: u16) -> (u16, u16, u16) {
279        let idx = i as usize;
280        if idx >= self.face_count as usize {
281            return (0, 0, 0);
282        }
283        let base = idx * self.index_stride as usize;
284        if self.index_stride as usize == MESH_U16_INDEX_STRIDE {
285            (
286                u16::from_le_bytes([self.indices[base], self.indices[base + 1]]),
287                u16::from_le_bytes([self.indices[base + 2], self.indices[base + 3]]),
288                u16::from_le_bytes([self.indices[base + 4], self.indices[base + 5]]),
289            )
290        } else {
291            (
292                self.indices[base] as u16,
293                self.indices[base + 1] as u16,
294                self.indices[base + 2] as u16,
295            )
296        }
297    }
298
299    /// Triangle `i`'s flat colour, or `None` if the blob doesn't
300    /// carry a face-colour table.
301    #[inline]
302    pub fn face_color(&self, i: u16) -> Option<(u8, u8, u8)> {
303        let colors = self.face_colors?;
304        let idx = i as usize;
305        if idx >= self.face_count as usize {
306            return None;
307        }
308        let base = idx * 3;
309        Some((colors[base], colors[base + 1], colors[base + 2]))
310    }
311
312    /// Per-vertex Q3.12 normal. Returns `None` if the blob lacks
313    /// a normal table (`HAS_NORMALS` flag clear) or `i` is out of
314    /// range. Components are unit-length-ish (Q3.12 quantisation
315    /// introduces sub-ULP error but the GTE lighting path is
316    /// tolerant).
317    #[inline]
318    pub fn vertex_normal(&self, i: u16) -> Option<Vec3I16> {
319        let normals = self.vertex_normals?;
320        let idx = i as usize;
321        if idx >= self.vert_count as usize {
322            return None;
323        }
324        let base = idx * 6;
325        let x = i16::from_le_bytes([normals[base], normals[base + 1]]);
326        let y = i16::from_le_bytes([normals[base + 2], normals[base + 3]]);
327        let z = i16::from_le_bytes([normals[base + 4], normals[base + 5]]);
328        Some(Vec3I16::new(x, y, z))
329    }
330}
331
332/// A parsed textured 3D model backed by slices into the caller's
333/// cooked `.psxmdl` blob.
334///
335/// This is intentionally a low-level view. It exposes the tables the
336/// renderer needs -- joints, materials, rigid parts, vertices, and
337/// faces -- without allocating or converting the whole model at load
338/// time.
339#[derive(Copy, Clone, Debug)]
340pub struct Model<'a> {
341    joints: &'a [u8],
342    materials: &'a [u8],
343    parts: &'a [u8],
344    vertices: &'a [u8],
345    faces: &'a [u8],
346    face_palette_banks: Option<&'a [u8]>,
347    joint_count: u16,
348    material_count: u16,
349    part_count: u16,
350    vertex_count: u16,
351    face_count: u16,
352    texture_width: u16,
353    texture_height: u16,
354    local_to_world_q12: u16,
355    flags: u16,
356}
357
358impl<'a> Model<'a> {
359    /// Parse a cooked `.psxmdl` blob.
360    pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, ParseError> {
361        use psxed_format::model::{
362            JointRecord, MaterialRecord, ModelHeader, PartRecord, FACE_RECORD_SIZE, LEGACY_VERSION,
363            MAGIC, VERSION, VERTEX_RECORD_SIZE,
364        };
365
366        if bytes.len() < psxed_format::AssetHeader::SIZE {
367            return Err(ParseError::Truncated);
368        }
369        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
370        if magic != MAGIC {
371            return Err(ParseError::WrongMagic);
372        }
373        let version = read_u16(bytes, 4);
374        if version != LEGACY_VERSION && version != VERSION {
375            return Err(ParseError::UnsupportedVersion(version));
376        }
377        let flags = read_u16(bytes, 6);
378        let payload_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
379        let payload_start = psxed_format::AssetHeader::SIZE;
380        let actual_payload = bytes.len().saturating_sub(payload_start);
381        if payload_len as usize != actual_payload {
382            return Err(ParseError::InvalidPayloadLen {
383                declared: payload_len,
384                actual: actual_payload,
385            });
386        }
387        if actual_payload < ModelHeader::SIZE {
388            return Err(ParseError::Truncated);
389        }
390
391        let mh = &bytes[payload_start..payload_start + ModelHeader::SIZE];
392        let joint_count = read_u16(mh, 0);
393        let part_count = read_u16(mh, 2);
394        let vertex_count = read_u16(mh, 4);
395        let face_count = read_u16(mh, 6);
396        let material_count = read_u16(mh, 8);
397        let texture_width = read_u16(mh, 10);
398        let texture_height = read_u16(mh, 12);
399        let local_to_world_q12 = read_u16(mh, 14);
400
401        let mut off = payload_start + ModelHeader::SIZE;
402
403        let joint_bytes = checked_table_bytes(joint_count, JointRecord::SIZE)?;
404        let joints = take_table(bytes, &mut off, joint_bytes)?;
405
406        let material_bytes = checked_table_bytes(material_count, MaterialRecord::SIZE)?;
407        let materials = take_table(bytes, &mut off, material_bytes)?;
408
409        let part_bytes = checked_table_bytes(part_count, PartRecord::SIZE)?;
410        let parts = take_table(bytes, &mut off, part_bytes)?;
411
412        let vertex_bytes = checked_table_bytes(vertex_count, VERTEX_RECORD_SIZE)?;
413        let vertices = take_table(bytes, &mut off, vertex_bytes)?;
414
415        let face_bytes = checked_table_bytes(face_count, FACE_RECORD_SIZE)?;
416        let faces = take_table(bytes, &mut off, face_bytes)?;
417
418        let has_face_palette_banks = flags & psxed_format::model::flags::FACE_PALETTE_BANKS != 0;
419        if version == LEGACY_VERSION && has_face_palette_banks {
420            return Err(ParseError::InvalidModelLayout);
421        }
422        let face_palette_banks = if has_face_palette_banks {
423            let bytes_needed = psxed_format::model::face_palette_bank_bytes(face_count);
424            Some(take_table(bytes, &mut off, bytes_needed)?)
425        } else {
426            None
427        };
428
429        if off != bytes.len() {
430            return Err(ParseError::InvalidModelLayout);
431        }
432        validate_model_parts(parts, joint_count, material_count, vertex_count, face_count)?;
433        validate_model_faces(faces, vertex_count)?;
434
435        Ok(Self {
436            joints,
437            materials,
438            parts,
439            vertices,
440            faces,
441            face_palette_banks,
442            joint_count,
443            material_count,
444            part_count,
445            vertex_count,
446            face_count,
447            texture_width,
448            texture_height,
449            local_to_world_q12,
450            flags,
451        })
452    }
453
454    /// Model feature flags (see [`psxed_format::model::flags`]).
455    #[inline]
456    pub fn flags(&self) -> u16 {
457        self.flags
458    }
459
460    /// Whether this model should render double-sided (no backface
461    /// culling). Set by the cooker for hollow / open-faced models.
462    #[inline]
463    pub fn is_double_sided(&self) -> bool {
464        self.flags & psxed_format::model::flags::DOUBLE_SIDED != 0
465    }
466
467    /// Number of joint records.
468    #[inline]
469    pub fn joint_count(&self) -> u16 {
470        self.joint_count
471    }
472
473    /// Number of material records.
474    #[inline]
475    pub fn material_count(&self) -> u16 {
476        self.material_count
477    }
478
479    /// Number of rigid part records.
480    #[inline]
481    pub fn part_count(&self) -> u16 {
482        self.part_count
483    }
484
485    /// Number of vertex records.
486    #[inline]
487    pub fn vertex_count(&self) -> u16 {
488        self.vertex_count
489    }
490
491    /// Number of triangle records.
492    #[inline]
493    pub fn face_count(&self) -> u16 {
494        self.face_count
495    }
496
497    /// Primary texture width in texels.
498    #[inline]
499    pub fn texture_width(&self) -> u16 {
500        self.texture_width
501    }
502
503    /// Primary texture height in texels.
504    #[inline]
505    pub fn texture_height(&self) -> u16 {
506        self.texture_height
507    }
508
509    /// Distance from the model origin down to the lowest bind-pose vertex,
510    /// in MODEL units (the vertex table's own units). Scale it with the same
511    /// local-to-world scale the mesh is drawn with and the feet land exactly
512    /// on the floor point; scaling it separately (or pre-scaling here) loses
513    /// up to a unit per rounding and leaves the feet hovering.
514    ///
515    /// Model space is Y-up; the origin is wherever the importer normalised it
516    /// (usually near, but not exactly at, mid-height), so this replaces the
517    /// "half of the authored height" guess.
518    pub fn bind_pose_floor_lift(&self) -> i32 {
519        let mut lowest = i32::MAX;
520        let mut i = 0u16;
521        while i < self.vertex_count() {
522            if let Some(vertex) = self.vertex(i) {
523                lowest = lowest.min(vertex.position.y as i32);
524            }
525            i = i.wrapping_add(1);
526            if i == 0 {
527                break;
528            }
529        }
530        if lowest == i32::MAX || lowest >= 0 {
531            return 0;
532        }
533        -lowest
534    }
535
536    /// Suggested uniform scale from model-local units to engine world units.
537    ///
538    /// `0x1000` is identity. Older blobs may store zero in the reserved
539    /// header slot; those are treated as identity.
540    #[inline]
541    pub fn local_to_world_q12(&self) -> u16 {
542        if self.local_to_world_q12 == 0 {
543            psxed_format::model::DEFAULT_LOCAL_TO_WORLD_Q12
544        } else {
545            self.local_to_world_q12
546        }
547    }
548
549    /// Joint record by index.
550    #[inline]
551    pub fn joint(&self, index: u16) -> Option<ModelJoint> {
552        if index >= self.joint_count {
553            return None;
554        }
555        let base = index as usize * psxed_format::model::JointRecord::SIZE;
556        let bytes = self
557            .joints
558            .get(base..base + psxed_format::model::JointRecord::SIZE)?;
559        Some(ModelJoint {
560            parent: read_u16(bytes, 0),
561        })
562    }
563
564    /// Material record by index.
565    #[inline]
566    pub fn material(&self, index: u16) -> Option<ModelMaterial> {
567        if index >= self.material_count {
568            return None;
569        }
570        let base = index as usize * psxed_format::model::MaterialRecord::SIZE;
571        let bytes = self
572            .materials
573            .get(base..base + psxed_format::model::MaterialRecord::SIZE)?;
574        Some(ModelMaterial {
575            texture_index: read_u16(bytes, 0),
576            flags: read_u16(bytes, 2),
577            base_color: [bytes[4], bytes[5], bytes[6], bytes[7]],
578        })
579    }
580
581    /// Rigid part record by index.
582    #[inline]
583    pub fn part(&self, index: u16) -> Option<ModelPart> {
584        if index >= self.part_count {
585            return None;
586        }
587        let base = index as usize * psxed_format::model::PartRecord::SIZE;
588        let bytes = self
589            .parts
590            .get(base..base + psxed_format::model::PartRecord::SIZE)?;
591        Some(ModelPart {
592            joint_index: read_u16(bytes, 0),
593            first_vertex: read_u16(bytes, 2),
594            vertex_count: read_u16(bytes, 4),
595            first_face: read_u16(bytes, 6),
596            face_count: read_u16(bytes, 8),
597            material_index: read_u16(bytes, 10),
598        })
599    }
600
601    /// Vertex record by global vertex index.
602    #[inline]
603    pub fn vertex(&self, index: u16) -> Option<ModelVertex> {
604        if index >= self.vertex_count {
605            return None;
606        }
607        let base = index as usize * psxed_format::model::VERTEX_RECORD_SIZE;
608        let bytes = self
609            .vertices
610            .get(base..base + psxed_format::model::VERTEX_RECORD_SIZE)?;
611        Some(ModelVertex {
612            position: Vec3I16::new(read_i16(bytes, 0), read_i16(bytes, 2), read_i16(bytes, 4)),
613            joint1: bytes[6],
614            blend: bytes[7],
615        })
616    }
617
618    /// Textured triangle by global face index.
619    #[inline]
620    pub fn face(&self, index: u16) -> Option<ModelFace> {
621        if index >= self.face_count {
622            return None;
623        }
624        let base = index as usize * psxed_format::model::FACE_RECORD_SIZE;
625        let bytes = self
626            .faces
627            .get(base..base + psxed_format::model::FACE_RECORD_SIZE)?;
628        Some(ModelFace {
629            corners: [
630                ModelFaceCorner {
631                    vertex_index: read_u16(bytes, 0),
632                    uv: (bytes[2], bytes[3]),
633                },
634                ModelFaceCorner {
635                    vertex_index: read_u16(bytes, 4),
636                    uv: (bytes[6], bytes[7]),
637                },
638                ModelFaceCorner {
639                    vertex_index: read_u16(bytes, 8),
640                    uv: (bytes[10], bytes[11]),
641                },
642            ],
643        })
644    }
645
646    /// 4bpp CLUT bank selected by a model face.
647    ///
648    /// Legacy and ordinary single-palette models return bank zero. `None`
649    /// means the face index is outside the model.
650    #[inline]
651    pub fn face_palette_bank(&self, index: u16) -> Option<u8> {
652        if index >= self.face_count {
653            return None;
654        }
655        let Some(packed) = self.face_palette_banks else {
656            return Some(0);
657        };
658        let byte = *packed.get(index as usize / 4)?;
659        Some((byte >> ((index as usize & 3) * 2)) & 3)
660    }
661
662    /// Number of consecutive 16-entry CLUT banks referenced by this model.
663    pub fn palette_bank_count(&self) -> u8 {
664        let mut highest = 0u8;
665        let mut index = 0u16;
666        while index < self.face_count {
667            highest = highest.max(self.face_palette_bank(index).unwrap_or(0));
668            index += 1;
669        }
670        highest + 1
671    }
672}
673
674/// Decoded model joint record.
675#[derive(Copy, Clone, Debug, PartialEq, Eq)]
676pub struct ModelJoint {
677    parent: u16,
678}
679
680impl ModelJoint {
681    /// Parent joint index, or `None` for a root joint.
682    #[inline]
683    pub fn parent(&self) -> Option<u16> {
684        if self.parent == psxed_format::model::NO_JOINT {
685            None
686        } else {
687            Some(self.parent)
688        }
689    }
690}
691
692/// Decoded model material record.
693#[derive(Copy, Clone, Debug, PartialEq, Eq)]
694pub struct ModelMaterial {
695    texture_index: u16,
696    flags: u16,
697    base_color: [u8; 4],
698}
699
700impl ModelMaterial {
701    /// Texture slot index.
702    #[inline]
703    pub fn texture_index(&self) -> u16 {
704        self.texture_index
705    }
706
707    /// Material flags.
708    #[inline]
709    pub fn flags(&self) -> u16 {
710        self.flags
711    }
712
713    /// Base colour/tint as RGBA8.
714    #[inline]
715    pub fn base_color(&self) -> [u8; 4] {
716        self.base_color
717    }
718}
719
720/// Decoded rigid part record.
721#[derive(Copy, Clone, Debug, PartialEq, Eq)]
722pub struct ModelPart {
723    joint_index: u16,
724    first_vertex: u16,
725    vertex_count: u16,
726    first_face: u16,
727    face_count: u16,
728    material_index: u16,
729}
730
731impl ModelPart {
732    /// Empty part used for fixed static/runtime pools.
733    pub const ZERO: Self = Self {
734        joint_index: 0,
735        first_vertex: 0,
736        vertex_count: 0,
737        first_face: 0,
738        face_count: 0,
739        material_index: 0,
740    };
741
742    /// Joint whose animation pose applies to this part.
743    #[inline]
744    pub fn joint_index(&self) -> u16 {
745        self.joint_index
746    }
747
748    /// First global vertex owned by this part.
749    #[inline]
750    pub fn first_vertex(&self) -> u16 {
751        self.first_vertex
752    }
753
754    /// Number of vertices owned by this part.
755    #[inline]
756    pub fn vertex_count(&self) -> u16 {
757        self.vertex_count
758    }
759
760    /// First global triangle owned by this part.
761    #[inline]
762    pub fn first_face(&self) -> u16 {
763        self.first_face
764    }
765
766    /// Number of triangles owned by this part.
767    #[inline]
768    pub fn face_count(&self) -> u16 {
769        self.face_count
770    }
771
772    /// Material slot used by this part.
773    #[inline]
774    pub fn material_index(&self) -> u16 {
775        self.material_index
776    }
777}
778
779/// Sentinel value for [`ModelVertex::joint1`] meaning "no secondary
780/// blend bone". Re-exported from `psxed_format::model` so runtime
781/// code does not need to depend on the editor crate.
782pub const NO_JOINT8: u8 = psxed_format::model::NO_JOINT8;
783
784/// Decoded textured model vertex.
785/// Word alignment lets the GTE projection path load XY in one MIPS load.
786/// These are decoded records, not a view into the cooked byte format.
787#[repr(C, align(4))]
788#[derive(Copy, Clone, Debug, PartialEq, Eq)]
789pub struct ModelVertex {
790    /// Model-local position. The cooked importer may use a much denser
791    /// scale here than world/grid units; use [`Model::local_to_world_q12`]
792    /// when placing the model directly into world space.
793    pub position: Vec3I16,
794    /// Secondary blend joint, or [`NO_JOINT8`] when this vertex is
795    /// single-bone.
796    pub joint1: u8,
797    /// Weight of `joint1` for view-space blending (0..=255). Zero
798    /// signals the renderer to stay on the single-bone GTE fast path.
799    pub blend: u8,
800}
801
802// Alignment must not grow the resident vertex pools.
803const _: () = assert!(core::mem::size_of::<ModelVertex>() == 8);
804
805impl ModelVertex {
806    /// Empty vertex record used by fixed-size runtime decode pools.
807    pub const ZERO: Self = Self {
808        position: Vec3I16::ZERO,
809        joint1: NO_JOINT8,
810        blend: 0,
811    };
812
813    /// `true` when this vertex needs the two-bone blend render path.
814    #[inline]
815    pub fn is_blend(&self) -> bool {
816        self.blend != 0 && self.joint1 != NO_JOINT8
817    }
818}
819
820/// One textured triangle corner in a cooked model face.
821#[derive(Copy, Clone, Debug, PartialEq, Eq)]
822pub struct ModelFaceCorner {
823    /// Global skinned vertex index.
824    pub vertex_index: u16,
825    /// 8-bit texture coordinate for this face corner.
826    pub uv: (u8, u8),
827}
828
829/// Decoded textured model face.
830#[derive(Copy, Clone, Debug, PartialEq, Eq)]
831pub struct ModelFace {
832    /// Three triangle corners in packet order.
833    pub corners: [ModelFaceCorner; 3],
834}
835
836/// A parsed rigid-skeletal animation backed by slices into the
837/// caller's cooked `.psxanim` blob.
838#[derive(Copy, Clone, Debug)]
839pub struct Animation<'a> {
840    pose_indices: &'a [u8],
841    poses: &'a [u8],
842    joint_count: u16,
843    frame_count: u16,
844    sample_rate_hz: u16,
845    pose_record_size: usize,
846    translation_shift: u8,
847}
848
849impl<'a> Animation<'a> {
850    /// Parse a cooked `.psxanim` blob.
851    pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, ParseError> {
852        use psxed_format::animation::{
853            AnimationHeader, MAGIC, POSE_RECORD_SIZE, POSE_RECORD_SIZE_V1, POSE_RECORD_SIZE_V3,
854            POSE_RECORD_SIZE_V4, VERSION, VERSION_V1, VERSION_V3, VERSION_V4, VERSION_V5,
855        };
856
857        if bytes.len() < psxed_format::AssetHeader::SIZE {
858            return Err(ParseError::Truncated);
859        }
860        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
861        if magic != MAGIC {
862            return Err(ParseError::WrongMagic);
863        }
864        let version = read_u16(bytes, 4);
865        let pose_record_size = match version {
866            VERSION => POSE_RECORD_SIZE,
867            VERSION_V1 => POSE_RECORD_SIZE_V1,
868            VERSION_V3 => POSE_RECORD_SIZE_V3,
869            VERSION_V4 | VERSION_V5 => POSE_RECORD_SIZE_V4,
870            _ => {
871                return Err(ParseError::UnsupportedVersion(version));
872            }
873        };
874        let payload_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
875        let payload_start = psxed_format::AssetHeader::SIZE;
876        let actual_payload = bytes.len().saturating_sub(payload_start);
877        if payload_len as usize != actual_payload {
878            return Err(ParseError::InvalidPayloadLen {
879                declared: payload_len,
880                actual: actual_payload,
881            });
882        }
883        if actual_payload < AnimationHeader::SIZE {
884            return Err(ParseError::Truncated);
885        }
886
887        let ah = &bytes[payload_start..payload_start + AnimationHeader::SIZE];
888        let joint_count = read_u16(ah, 0);
889        let frame_count = read_u16(ah, 2);
890        let sample_rate_hz = read_u16(ah, 4);
891        let translation_shift = if version == VERSION
892            || version == VERSION_V3
893            || version == VERSION_V4
894            || version == VERSION_V5
895        {
896            let shift = read_u16(ah, 6);
897            if shift > 15 {
898                return Err(ParseError::InvalidAnimationLayout);
899            }
900            shift as u8
901        } else {
902            0
903        };
904        if joint_count == 0 || frame_count == 0 || sample_rate_hz == 0 {
905            return Err(ParseError::InvalidAnimationLayout);
906        }
907
908        let mut off = payload_start + AnimationHeader::SIZE;
909        let pose_count = (joint_count as usize)
910            .checked_mul(frame_count as usize)
911            .ok_or(ParseError::TableOverflow)?;
912        let (pose_indices, poses) = if version == VERSION_V5 {
913            let index_bytes = pose_count.checked_mul(2).ok_or(ParseError::TableOverflow)?;
914            let indices = take_table(bytes, &mut off, index_bytes)?;
915            off = (off + 3) & !3;
916            let poses = bytes.get(off..).ok_or(ParseError::Truncated)?;
917            if poses.is_empty() || poses.len() % POSE_RECORD_SIZE_V4 != 0 {
918                return Err(ParseError::InvalidAnimationLayout);
919            }
920            let records = poses.len() / POSE_RECORD_SIZE_V4;
921            for index in indices.chunks_exact(2) {
922                if read_u16(index, 0) as usize >= records {
923                    return Err(ParseError::InvalidAnimationLayout);
924                }
925            }
926            (indices, poses)
927        } else {
928            let pose_bytes = pose_count
929                .checked_mul(pose_record_size)
930                .ok_or(ParseError::TableOverflow)?;
931            let poses = take_table(bytes, &mut off, pose_bytes)?;
932            if off != bytes.len() {
933                return Err(ParseError::InvalidAnimationLayout);
934            }
935            (&[][..], poses)
936        };
937
938        Ok(Self {
939            pose_indices,
940            poses,
941            joint_count,
942            frame_count,
943            sample_rate_hz,
944            pose_record_size,
945            translation_shift,
946        })
947    }
948
949    /// Number of joint poses per frame.
950    #[inline]
951    pub fn joint_count(&self) -> u16 {
952        self.joint_count
953    }
954
955    /// Number of sampled frames.
956    #[inline]
957    pub fn frame_count(&self) -> u16 {
958        self.frame_count
959    }
960
961    /// Integer sample rate in Hz.
962    #[inline]
963    pub fn sample_rate_hz(&self) -> u16 {
964        self.sample_rate_hz
965    }
966
967    /// Byte size of one stored pose record.
968    #[inline]
969    pub fn pose_record_size(&self) -> usize {
970        self.pose_record_size
971    }
972
973    /// Shared right shift used by compact v2 stored translations.
974    #[inline]
975    pub fn translation_shift(&self) -> u8 {
976        self.translation_shift
977    }
978
979    /// Q12 sampled-frame phase advance for one playback tick.
980    ///
981    /// `playback_hz` is the caller's update cadence. For example,
982    /// a 15 Hz cooked clip played by a 30 Hz update loop advances by
983    /// half a sampled frame per tick (`0x0800`).
984    #[inline]
985    pub fn phase_step_q12(&self, playback_hz: u16) -> u32 {
986        ((self.sample_rate_hz as u32) << 12) / playback_hz.max(1) as u32
987    }
988
989    /// Convert a fixed-rate playback tick to a Q12 sampled-frame phase.
990    ///
991    /// This is a convenience for frame-locked demos. More advanced
992    /// scenes can accumulate [`Animation::phase_step_q12`] themselves
993    /// when playback speed, pausing, or elapsed-time correction matters.
994    #[inline]
995    pub fn phase_at_tick_q12(&self, playback_tick: u32, playback_hz: u16) -> u32 {
996        playback_tick.wrapping_mul(self.phase_step_q12(playback_hz))
997    }
998
999    /// Like [`Animation::phase_at_tick_q12`] but scales the advance by a
1000    /// Q8 speed multiplier (`256 = 1.0x`): `< 256` plays slower, `> 256`
1001    /// faster. Scaling the per-tick step rather than the accumulated
1002    /// phase keeps looping wrap-around identical to unscaled playback.
1003    #[inline]
1004    pub fn phase_at_tick_scaled_q12(
1005        &self,
1006        playback_tick: u32,
1007        playback_hz: u16,
1008        speed_q8: u16,
1009    ) -> u32 {
1010        // `step * speed_q8 / 256` in u32: the high byte of the step scales
1011        // whole, the low byte contributes its floored share, and the sum
1012        // equals the floor of the full product over 256 (wrapping like the
1013        // former u64 form's `as u32`).
1014        let step = self.phase_step_q12(playback_hz);
1015        let speed = u32::from(speed_q8);
1016        let scaled_step = (step >> 8)
1017            .wrapping_mul(speed)
1018            .wrapping_add(((step & 0xff) * speed) >> 8);
1019        playback_tick.wrapping_mul(scaled_step)
1020    }
1021
1022    /// Joint pose at `frame_index`, `joint_index`.
1023    pub fn pose(&self, frame_index: u16, joint_index: u16) -> Option<JointPose> {
1024        if frame_index >= self.frame_count || joint_index >= self.joint_count {
1025            return None;
1026        }
1027        let base = frame_index as usize * self.joint_count as usize * self.pose_record_size;
1028        // SAFETY: both indices were checked above. `from_bytes` validates
1029        // the complete frame/joint table before constructing `Animation`.
1030        Some(unsafe { self.pose_at_frame_offset_unchecked(base, joint_index) })
1031    }
1032
1033    #[inline]
1034    fn record_offset(&self, logical_offset: usize) -> usize {
1035        if self.pose_indices.is_empty() {
1036            logical_offset
1037        } else {
1038            // Dictionary clips use 16-byte records. The parser checked every
1039            // index before any unchecked pose sampling can reach this table.
1040            usize::from(read_u16(self.pose_indices, logical_offset >> 3)) << 4
1041        }
1042    }
1043
1044    /// Decode the joint record `joint_index` of the frame starting at
1045    /// logical byte offset `frame_offset`.
1046    ///
1047    /// # Safety
1048    /// `frame_offset` must be `frame * joint_count * pose_record_size` for a
1049    /// frame `< frame_count`, and `joint_index < joint_count`. `from_bytes`
1050    /// sized `poses` (or validated every dictionary index) so that such a
1051    /// record lies wholly inside `poses`.
1052    #[inline]
1053    unsafe fn pose_at_frame_offset_unchecked(
1054        &self,
1055        frame_offset: usize,
1056        joint_index: u16,
1057    ) -> JointPose {
1058        let base = self.record_offset(frame_offset + joint_index as usize * self.pose_record_size);
1059        if self.poses.as_ptr() as usize & 1 != 0 {
1060            // SAFETY: per this fn's `# Safety` section the whole record at
1061            // `base` is inside `poses`; the byte decoder needs no alignment.
1062            return unsafe { self.pose_at_byte_offset_unaligned(base) };
1063        }
1064        if self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE_V4 {
1065            // SAFETY: the record is `POSE_RECORD_SIZE_V4` (16) bytes and lies
1066            // inside `poses` per this fn's `# Safety` section, so `base + 16`
1067            // is in bounds; `pose_v4_unchecked` checks alignment itself.
1068            return unsafe { self.pose_v4_unchecked(base) };
1069        }
1070        if self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE_V3 {
1071            // SAFETY: the record is `POSE_RECORD_SIZE_V3` (20) bytes and lies
1072            // inside `poses` per this fn's `# Safety` section, so `base + 20`
1073            // is in bounds; `pose_v3_unchecked` checks alignment itself.
1074            return unsafe { self.pose_v3_unchecked(base) };
1075        }
1076        // Only v1 (30-byte) and v2 (24-byte) records reach here. Both are
1077        // non-dictionary, so `base` is a multiple of an even record size,
1078        // and `poses` is 2-byte aligned (checked above): every i16 below is
1079        // aligned. The record covers bytes `base..base + 24` at least.
1080        // SAFETY: the 18-byte matrix at `base` is inside the in-bounds record
1081        // (this fn's `# Safety` section) and 2-byte aligned as argued above.
1082        let matrix = unsafe { read_pose_matrix_aligned_unchecked(self.poses, base) };
1083        let off = 18;
1084        let translation = if self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE {
1085            // v2 records are 24 bytes, so `base + 18..base + 24` is inside
1086            // the in-bounds record, and each offset is even on a 2-byte-aligned
1087            // pool.
1088            Vec3I32::new(
1089                decode_packed_translation(
1090                    // SAFETY: bytes `base + 18..base + 20` of the 24-byte record, even offset.
1091                    unsafe { read_i16_aligned_unchecked(self.poses, base + off) },
1092                    self.translation_shift,
1093                ),
1094                decode_packed_translation(
1095                    // SAFETY: bytes `base + 20..base + 22` of the 24-byte record, even offset.
1096                    unsafe { read_i16_aligned_unchecked(self.poses, base + off + 2) },
1097                    self.translation_shift,
1098                ),
1099                decode_packed_translation(
1100                    // SAFETY: bytes `base + 22..base + 24` of the 24-byte record, even offset.
1101                    unsafe { read_i16_aligned_unchecked(self.poses, base + off + 4) },
1102                    self.translation_shift,
1103                ),
1104            )
1105        } else {
1106            // v1 records are 30 bytes: three byte-wise i32 reads at
1107            // `base + 18..base + 30`, all inside the in-bounds record.
1108            Vec3I32::new(
1109                // SAFETY: bytes `base + 18..base + 22` of the v1 record.
1110                unsafe { read_i32_unchecked(self.poses, base + off) },
1111                // SAFETY: bytes `base + 22..base + 26` of the v1 record.
1112                unsafe { read_i32_unchecked(self.poses, base + off + 4) },
1113                // SAFETY: bytes `base + 26..base + 30` of the v1 record.
1114                unsafe { read_i32_unchecked(self.poses, base + off + 8) },
1115            )
1116        };
1117        JointPose {
1118            matrix,
1119            translation,
1120        }
1121    }
1122
1123    /// Decode one v3 (Q11-packed) record: rotation block at +0,
1124    /// shifted i16 translations at +14.
1125    ///
1126    /// Shipping assets are word-aligned by both the generated static wrapper
1127    /// and the persistent CD asset pool. That path reads the complete 20-byte
1128    /// record as five words; generic callers can still pass an arbitrarily
1129    /// aligned byte slice and use the byte-exact fallback.
1130    ///
1131    /// # Safety
1132    /// `base + 20` must be in bounds.
1133    unsafe fn pose_v3_unchecked(&self, base: usize) -> JointPose {
1134        // SAFETY: `base + 20 <= poses.len()` (this fn's `# Safety` section),
1135        // so `base` is within the allocation.
1136        let record = unsafe { self.poses.as_ptr().add(base) };
1137        let (matrix, packed_translation) = if record as usize & 3 == 0 {
1138            // SAFETY: `base + 20` is in bounds and the record pointer was just
1139            // checked to be word aligned.
1140            unsafe { read_pose_v3_word_aligned_unchecked(self.poses, base) }
1141        } else {
1142            // SAFETY: needs `base + 14` in bounds; `base + 20` is.
1143            let matrix = unsafe { read_pose_matrix_q11_unchecked(self.poses, base) };
1144            let off = psxed_format::animation::POSE_ROTATION_BLOCK_SIZE_V3;
1145            let translation = Vec3I16::new(
1146                // SAFETY: bytes `base + 14..base + 16`, inside `base + 20`.
1147                unsafe { read_i16_unchecked(self.poses, base + off) },
1148                // SAFETY: bytes `base + 16..base + 18`, inside `base + 20`.
1149                unsafe { read_i16_unchecked(self.poses, base + off + 2) },
1150                // SAFETY: bytes `base + 18..base + 20`, inside `base + 20`.
1151                unsafe { read_i16_unchecked(self.poses, base + off + 4) },
1152            );
1153            (matrix, translation)
1154        };
1155        let translation = Vec3I32::new(
1156            decode_packed_translation(packed_translation.x, self.translation_shift),
1157            decode_packed_translation(packed_translation.y, self.translation_shift),
1158            decode_packed_translation(packed_translation.z, self.translation_shift),
1159        );
1160        JointPose {
1161            matrix,
1162            translation,
1163        }
1164    }
1165
1166    /// Decode one v4 record: two packed basis vectors plus correction at +0,
1167    /// followed by shifted `i16` translations at +10.
1168    ///
1169    /// # Safety
1170    /// `base + 16` must be in bounds.
1171    unsafe fn pose_v4_unchecked(&self, base: usize) -> JointPose {
1172        // SAFETY: `base + 16 <= poses.len()` (this fn's `# Safety` section),
1173        // so `base` is within the allocation.
1174        let record = unsafe { self.poses.as_ptr().add(base) };
1175        let (matrix, packed_translation) = if record as usize & 3 == 0 {
1176            // SAFETY: `base + 16` is in bounds and the record pointer was just
1177            // checked to be word aligned.
1178            unsafe { read_pose_v4_word_aligned_unchecked(self.poses, base) }
1179        } else {
1180            // SAFETY: needs `base + 10` in bounds; `base + 16` is.
1181            let matrix = unsafe { read_pose_matrix_q11_cross_unchecked(self.poses, base) };
1182            let off = psxed_format::animation::POSE_ROTATION_BLOCK_SIZE_V4;
1183            let translation = Vec3I16::new(
1184                // SAFETY: bytes `base + 10..base + 12`, inside `base + 16`.
1185                unsafe { read_i16_unchecked(self.poses, base + off) },
1186                // SAFETY: bytes `base + 12..base + 14`, inside `base + 16`.
1187                unsafe { read_i16_unchecked(self.poses, base + off + 2) },
1188                // SAFETY: bytes `base + 14..base + 16`, inside `base + 16`.
1189                unsafe { read_i16_unchecked(self.poses, base + off + 4) },
1190            );
1191            (matrix, translation)
1192        };
1193        JointPose {
1194            matrix,
1195            translation: Vec3I32::new(
1196                decode_packed_translation(packed_translation.x, self.translation_shift),
1197                decode_packed_translation(packed_translation.y, self.translation_shift),
1198                decode_packed_translation(packed_translation.z, self.translation_shift),
1199            ),
1200        }
1201    }
1202
1203    /// Byte-wise decode of the record at `base`, for pools with no
1204    /// alignment guarantee.
1205    ///
1206    /// # Safety
1207    /// The whole `pose_record_size`-byte record at `base` must lie inside
1208    /// `poses`.
1209    #[inline(never)]
1210    unsafe fn pose_at_byte_offset_unaligned(&self, base: usize) -> JointPose {
1211        if self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE_V4 {
1212            // SAFETY: the 16-byte record is in bounds per this fn's `# Safety`.
1213            return unsafe { self.pose_v4_unchecked(base) };
1214        }
1215        if self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE_V3 {
1216            // SAFETY: the 20-byte record is in bounds per this fn's `# Safety`.
1217            return unsafe { self.pose_v3_unchecked(base) };
1218        }
1219        // Only v1 (30-byte) and v2 (24-byte) records reach here.
1220        // SAFETY: the 18-byte matrix lies inside the in-bounds record;
1221        // `read_pose_matrix_unchecked` reads byte-wise, no alignment needed.
1222        let matrix = unsafe { read_pose_matrix_unchecked(self.poses, base) };
1223        let off = 18;
1224        let translation = if self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE {
1225            Vec3I32::new(
1226                decode_packed_translation(
1227                    // SAFETY: bytes `base + 18..base + 20` of the 24-byte record.
1228                    unsafe { read_i16_unchecked(self.poses, base + off) },
1229                    self.translation_shift,
1230                ),
1231                decode_packed_translation(
1232                    // SAFETY: bytes `base + 20..base + 22` of the 24-byte record.
1233                    unsafe { read_i16_unchecked(self.poses, base + off + 2) },
1234                    self.translation_shift,
1235                ),
1236                decode_packed_translation(
1237                    // SAFETY: bytes `base + 22..base + 24` of the 24-byte record.
1238                    unsafe { read_i16_unchecked(self.poses, base + off + 4) },
1239                    self.translation_shift,
1240                ),
1241            )
1242        } else {
1243            Vec3I32::new(
1244                // SAFETY: bytes `base + 18..base + 22` of the 30-byte v1 record.
1245                unsafe { read_i32_unchecked(self.poses, base + off) },
1246                // SAFETY: bytes `base + 22..base + 26` of the 30-byte v1 record.
1247                unsafe { read_i32_unchecked(self.poses, base + off + 4) },
1248                // SAFETY: bytes `base + 26..base + 30` of the 30-byte v1 record.
1249                unsafe { read_i32_unchecked(self.poses, base + off + 8) },
1250            )
1251        };
1252        JointPose {
1253            matrix,
1254            translation,
1255        }
1256    }
1257
1258    #[inline]
1259    fn packed_pose_at_frame_offset(
1260        &self,
1261        frame_offset: usize,
1262        joint_index: u16,
1263    ) -> Option<GteJointPose> {
1264        let v3 = self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE_V3;
1265        let v4 = self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE_V4;
1266        if joint_index >= self.joint_count
1267            || !(v3 || v4 || self.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE)
1268        {
1269            return None;
1270        }
1271        let base = frame_offset.checked_add(joint_index as usize * self.pose_record_size)?;
1272        let base = self.record_offset(base);
1273        let bytes = self.poses.get(base..base + self.pose_record_size)?;
1274        if v4 {
1275            let (matrix, translation) = if bytes.as_ptr() as usize & 3 == 0 {
1276                // SAFETY: `bytes` is a checked `get` of exactly the 16-byte v4
1277                // record and its pointer was just checked to be word aligned.
1278                unsafe { read_pose_v4_word_aligned_unchecked(bytes, 0) }
1279            } else {
1280                // SAFETY: the 16-byte checked slice covers the 10 bytes needed.
1281                let matrix = unsafe { read_pose_matrix_q11_cross_unchecked(bytes, 0) };
1282                let off = psxed_format::animation::POSE_ROTATION_BLOCK_SIZE_V4;
1283                let translation = Vec3I16::new(
1284                    read_i16(bytes, off),
1285                    read_i16(bytes, off + 2),
1286                    read_i16(bytes, off + 4),
1287                );
1288                (matrix, translation)
1289            };
1290            return Some(GteJointPose {
1291                matrix,
1292                translation,
1293                translation_shift: self.translation_shift,
1294            });
1295        }
1296        if v3 {
1297            // SAFETY: the slice covers the whole 20-byte record. Shipping
1298            // storage is word-aligned; retain the byte decoder for arbitrary
1299            // slices accepted by the public parser.
1300            let (matrix, translation) = if bytes.as_ptr() as usize & 3 == 0 {
1301                // SAFETY: as stated above, plus the word alignment just checked.
1302                unsafe { read_pose_v3_word_aligned_unchecked(bytes, 0) }
1303            } else {
1304                // SAFETY: the 20-byte checked slice covers the 14 bytes needed.
1305                let matrix = unsafe { read_pose_matrix_q11_unchecked(bytes, 0) };
1306                let off = psxed_format::animation::POSE_ROTATION_BLOCK_SIZE_V3;
1307                let translation = Vec3I16::new(
1308                    read_i16(bytes, off),
1309                    read_i16(bytes, off + 2),
1310                    read_i16(bytes, off + 4),
1311                );
1312                (matrix, translation)
1313            };
1314            return Some(GteJointPose {
1315                matrix,
1316                translation,
1317                translation_shift: self.translation_shift,
1318            });
1319        }
1320        Some(GteJointPose {
1321            matrix: read_pose_matrix(bytes),
1322            translation: Vec3I16::new(
1323                read_i16(bytes, 18),
1324                read_i16(bytes, 20),
1325                read_i16(bytes, 22),
1326            ),
1327            translation_shift: self.translation_shift,
1328        })
1329    }
1330
1331    /// Interpolated looping joint pose at a Q12 fixed-point frame phase.
1332    ///
1333    /// `frame_q12` uses sampled animation frames as the integer unit
1334    /// and 12 fractional bits. For example, `1 << 12` samples frame
1335    /// 1 exactly, while `1 << 11` samples halfway between frames 0
1336    /// and 1. The cooker writes endpoint-inclusive clips, so looping
1337    /// playback treats the final stored frame as the duplicate of
1338    /// frame 0 and blends `frame_count - 2` back to frame 0.
1339    pub fn pose_looped_q12(&self, frame_q12: u32, joint_index: u16) -> Option<JointPose> {
1340        self.looped_pose_sample_q12(frame_q12)?.pose(joint_index)
1341    }
1342
1343    /// Precomputed looping frame pair for sampling multiple joints at
1344    /// the same animation phase.
1345    ///
1346    /// Renderers should prefer this when walking every joint in a
1347    /// model: the expensive loop-frame modulo and alpha calculation
1348    /// happens once per draw instead of once per joint.
1349    pub fn looped_pose_sample_q12(&self, frame_q12: u32) -> Option<AnimationPoseSample<'a>> {
1350        if self.frame_count == 0 {
1351            return None;
1352        }
1353
1354        let cycle_frames = self.frame_count.saturating_sub(1).max(1);
1355        let base_frame = ((frame_q12 >> 12) % cycle_frames as u32) as u16;
1356        let next_frame = if cycle_frames <= 1 || base_frame + 1 >= cycle_frames {
1357            0
1358        } else {
1359            base_frame + 1
1360        };
1361        Some(AnimationPoseSample {
1362            animation: *self,
1363            base_frame,
1364            next_frame,
1365            base_frame_offset: base_frame as usize
1366                * self.joint_count as usize
1367                * self.pose_record_size,
1368            next_frame_offset: next_frame as usize
1369                * self.joint_count as usize
1370                * self.pose_record_size,
1371            alpha_q12: (frame_q12 & 0x0fff) as u16,
1372        })
1373    }
1374}
1375
1376/// Reusable frame-pair sampler for one animation phase.
1377#[derive(Copy, Clone, Debug)]
1378pub struct AnimationPoseSample<'a> {
1379    animation: Animation<'a>,
1380    base_frame: u16,
1381    next_frame: u16,
1382    base_frame_offset: usize,
1383    next_frame_offset: usize,
1384    alpha_q12: u16,
1385}
1386
1387impl AnimationPoseSample<'_> {
1388    /// Joint pose at this sample's precomputed looping phase.
1389    #[inline]
1390    pub fn pose(&self, joint_index: u16) -> Option<JointPose> {
1391        if joint_index >= self.animation.joint_count {
1392            return None;
1393        }
1394        if self.alpha_q12 == 0 || self.base_frame == self.next_frame {
1395            // SAFETY: the joint index was checked above and the frame offset
1396            // was computed from a validated animation frame.
1397            return Some(unsafe {
1398                self.animation
1399                    .pose_at_frame_offset_unchecked(self.base_frame_offset, joint_index)
1400            });
1401        }
1402
1403        // Shipping clips are v4 records inside a word-aligned pool, which is
1404        // the one shape every skinned draw walks joint by joint. Decoding both
1405        // frames in one body instead of two outlined calls plus an outlined
1406        // lerp removes two 32-byte `JointPose` temporaries that the R3000A,
1407        // having no data cache, would otherwise write to and read back from
1408        // main RAM, and it loads the record base and the shared translation
1409        // shift once instead of twice. The values are bit-identical to the
1410        // generic path below: `pose_record_size == V4` and a word-aligned pool
1411        // select exactly the same decoder, and every v4 record offset is a
1412        // multiple of 16, so the whole-pool alignment test settles both
1413        // records at once.
1414        if self.animation.pose_record_size == psxed_format::animation::POSE_RECORD_SIZE_V4 {
1415            let pool = self.animation.poses.as_ptr();
1416            if pool as usize & 3 == 0 {
1417                let joint_offset =
1418                    joint_index as usize * psxed_format::animation::POSE_RECORD_SIZE_V4;
1419                // SAFETY: the joint index was checked above and both frame
1420                // offsets were computed from validated animation frames, so
1421                // both records lie wholly inside `poses`; the pool is word
1422                // aligned and both offsets are multiples of the 16-byte record.
1423                return Some(unsafe {
1424                    lerp_v4_pair_word_aligned(
1425                        pool.add(
1426                            self.animation
1427                                .record_offset(self.base_frame_offset + joint_offset),
1428                        ),
1429                        pool.add(
1430                            self.animation
1431                                .record_offset(self.next_frame_offset + joint_offset),
1432                        ),
1433                        self.animation.translation_shift,
1434                        self.alpha_q12,
1435                    )
1436                });
1437            }
1438        }
1439
1440        // SAFETY: the joint index was checked above and both frame offsets
1441        // were computed from validated animation frames.
1442        let a = unsafe {
1443            self.animation
1444                .pose_at_frame_offset_unchecked(self.base_frame_offset, joint_index)
1445        };
1446        // SAFETY: as for `a`: `next_frame < frame_count` by construction in
1447        // `looped_pose_sample_q12`, and the joint index was checked above.
1448        let b = unsafe {
1449            self.animation
1450                .pose_at_frame_offset_unchecked(self.next_frame_offset, joint_index)
1451        };
1452        Some(lerp_pose_q12(a, b, self.alpha_q12))
1453    }
1454
1455    /// Packed GTE-friendly joint pose at this sample's phase.
1456    ///
1457    /// This is available for current v2 `.psxanim` files. Legacy v1
1458    /// files return `None` and callers can fall back to [`Self::pose`].
1459    #[inline]
1460    pub fn gte_pose(&self, joint_index: u16) -> Option<GteJointPose> {
1461        if self.alpha_q12 == 0 || self.base_frame == self.next_frame {
1462            return self
1463                .animation
1464                .packed_pose_at_frame_offset(self.base_frame_offset, joint_index);
1465        }
1466
1467        let a = self
1468            .animation
1469            .packed_pose_at_frame_offset(self.base_frame_offset, joint_index)?;
1470        let b = self
1471            .animation
1472            .packed_pose_at_frame_offset(self.next_frame_offset, joint_index)?;
1473        Some(lerp_gte_pose_q12(a, b, self.alpha_q12))
1474    }
1475}
1476
1477/// Crossfade source: a second animation sample blended toward a
1478/// primary pose.
1479///
1480/// Used for clip-transition crossfades: the outgoing clip's frozen
1481/// sample sits here while the incoming clip plays as the primary.
1482/// `alpha_q12` is the Q12 weight of the PRIMARY pose (0 shows this
1483/// source, `1 << 12` shows the primary alone).
1484///
1485/// The blend is a linear matrix lerp, not a rotation-correct slerp:
1486/// mid-blend joints shrink slightly when the two poses differ a lot.
1487/// Short crossfade windows keep that invisible; renormalize later if
1488/// a long blend ever makes it visible.
1489#[derive(Copy, Clone, Debug)]
1490pub struct ModelPoseBlend<'a> {
1491    /// Outgoing pose sample (frame pair + intra-frame alpha).
1492    pub sample: AnimationPoseSample<'a>,
1493    /// Q12 weight of the primary pose this source blends toward.
1494    pub alpha_q12: u16,
1495}
1496
1497impl ModelPoseBlend<'_> {
1498    /// Blend this source's joint pose toward `primary`.
1499    ///
1500    /// Falls back to `primary` unchanged when the joint is missing
1501    /// from the outgoing clip (mismatched rigs never corrupt poses).
1502    #[inline]
1503    pub fn blend_toward(&self, primary: JointPose, joint_index: u16) -> JointPose {
1504        if self.alpha_q12 >= 1 << 12 {
1505            return primary;
1506        }
1507        match self.sample.pose(joint_index) {
1508            Some(from) => lerp_pose_q12(from, primary, self.alpha_q12),
1509            None => primary,
1510        }
1511    }
1512}
1513
1514/// Decoded joint pose matrix.
1515#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1516pub struct JointPose {
1517    /// Q3.12 column-major 3×3 transform.
1518    pub matrix: [[i16; 3]; 3],
1519    /// Q3.12 translation vector.
1520    pub translation: Vec3I32,
1521}
1522
1523/// Compact joint pose that can feed GTE vector inputs directly.
1524#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1525pub struct GteJointPose {
1526    /// Q3.12 column-major 3×3 transform.
1527    pub matrix: [[i16; 3]; 3],
1528    /// Packed model-local translation vector.
1529    pub translation: Vec3I16,
1530    /// Shared left shift used to reconstruct model-local units.
1531    pub translation_shift: u8,
1532}
1533
1534/// A parsed `.psau` SPU audio sample backed by the caller's cooked blob.
1535#[derive(Copy, Clone, Debug)]
1536pub struct Audio<'a> {
1537    adpcm: &'a [u8],
1538    flags: u16,
1539    sample_rate_hz: u32,
1540    sample_count: u32,
1541    adpcm_block_count: u32,
1542    loop_start_block: u32,
1543}
1544
1545impl<'a> Audio<'a> {
1546    /// Parse a cooked `.psau` blob.
1547    pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, ParseError> {
1548        use psxed_format::audio::{self, AudioHeader};
1549
1550        if bytes.len() < psxed_format::AssetHeader::SIZE {
1551            return Err(ParseError::Truncated);
1552        }
1553        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
1554        if magic != audio::MAGIC {
1555            return Err(ParseError::WrongMagic);
1556        }
1557        let version = read_u16(bytes, 4);
1558        if version != audio::VERSION {
1559            return Err(ParseError::UnsupportedVersion(version));
1560        }
1561        let flags = read_u16(bytes, 6);
1562        let payload_len = read_u32(bytes, 8);
1563        let payload_start = psxed_format::AssetHeader::SIZE;
1564        let actual_payload = bytes.len().saturating_sub(payload_start);
1565        if payload_len as usize != actual_payload {
1566            return Err(ParseError::InvalidPayloadLen {
1567                declared: payload_len,
1568                actual: actual_payload,
1569            });
1570        }
1571        if actual_payload < AudioHeader::SIZE {
1572            return Err(ParseError::Truncated);
1573        }
1574
1575        let ah = &bytes[payload_start..payload_start + AudioHeader::SIZE];
1576        let codec = ah[0];
1577        let channel_count = ah[1];
1578        let sample_rate_hz = read_u32(ah, 4);
1579        let sample_count = read_u32(ah, 8);
1580        let adpcm_block_count = read_u32(ah, 12);
1581        let loop_start_block = read_u32(ah, 16);
1582        let decoded_capacity = adpcm_block_count
1583            .checked_mul(28)
1584            .ok_or(ParseError::TableOverflow)?;
1585        let min_samples_for_blocks = adpcm_block_count.saturating_sub(1) * 28 + 1;
1586        if codec != audio::CODEC_SPU_ADPCM
1587            || channel_count != 1
1588            || flags & audio::flags::MONO == 0
1589            || flags & audio::flags::ONE_SHOT == 0
1590            || sample_rate_hz == 0
1591            || sample_count == 0
1592            || adpcm_block_count == 0
1593            || sample_count > decoded_capacity
1594            || sample_count < min_samples_for_blocks
1595            || loop_start_block != AudioHeader::NO_LOOP
1596        {
1597            return Err(ParseError::InvalidAudioLayout);
1598        }
1599
1600        let adpcm_bytes = (adpcm_block_count as usize)
1601            .checked_mul(16)
1602            .ok_or(ParseError::TableOverflow)?;
1603        let adpcm_start = payload_start + AudioHeader::SIZE;
1604        let adpcm_end = adpcm_start
1605            .checked_add(adpcm_bytes)
1606            .ok_or(ParseError::TableOverflow)?;
1607        if adpcm_end != bytes.len() {
1608            return Err(ParseError::InvalidAudioLayout);
1609        }
1610
1611        Ok(Self {
1612            adpcm: &bytes[adpcm_start..adpcm_end],
1613            flags,
1614            sample_rate_hz,
1615            sample_count,
1616            adpcm_block_count,
1617            loop_start_block,
1618        })
1619    }
1620
1621    /// Playback sample rate in Hz.
1622    #[inline]
1623    pub fn sample_rate_hz(&self) -> u32 {
1624        self.sample_rate_hz
1625    }
1626
1627    /// Audible mono sample count before encoder padding.
1628    #[inline]
1629    pub fn sample_count(&self) -> u32 {
1630        self.sample_count
1631    }
1632
1633    /// Number of 16-byte SPU ADPCM blocks.
1634    #[inline]
1635    pub fn adpcm_block_count(&self) -> u32 {
1636        self.adpcm_block_count
1637    }
1638
1639    /// Loop-start block, or `None` for one-shot samples.
1640    #[inline]
1641    pub fn loop_start_block(&self) -> Option<u32> {
1642        if self.loop_start_block == psxed_format::audio::AudioHeader::NO_LOOP {
1643            None
1644        } else {
1645            Some(self.loop_start_block)
1646        }
1647    }
1648
1649    /// Raw SPU ADPCM bytes ready for upload.
1650    #[inline]
1651    pub fn adpcm_bytes(&self) -> &'a [u8] {
1652        self.adpcm
1653    }
1654
1655    /// Whether this sample is a non-looping one-shot.
1656    #[inline]
1657    pub fn is_one_shot(&self) -> bool {
1658        self.flags & psxed_format::audio::flags::ONE_SHOT != 0
1659    }
1660}
1661
1662#[inline]
1663fn lerp_pose_q12(a: JointPose, b: JointPose, alpha_q12: u16) -> JointPose {
1664    let mut matrix = [[0i16; 3]; 3];
1665    let mut col = 0;
1666    while col < 3 {
1667        let mut row = 0;
1668        while row < 3 {
1669            matrix[col][row] = lerp_i16_q12(a.matrix[col][row], b.matrix[col][row], alpha_q12);
1670            row += 1;
1671        }
1672        col += 1;
1673    }
1674
1675    JointPose {
1676        matrix,
1677        translation: Vec3I32::new(
1678            lerp_i32_q12(a.translation.x, b.translation.x, alpha_q12),
1679            lerp_i32_q12(a.translation.y, b.translation.y, alpha_q12),
1680            lerp_i32_q12(a.translation.z, b.translation.z, alpha_q12),
1681        ),
1682    }
1683}
1684
1685#[inline]
1686fn lerp_gte_pose_q12(a: GteJointPose, b: GteJointPose, alpha_q12: u16) -> GteJointPose {
1687    let mut matrix = [[0i16; 3]; 3];
1688    let mut col = 0;
1689    while col < 3 {
1690        let mut row = 0;
1691        while row < 3 {
1692            matrix[col][row] = lerp_i16_q12(a.matrix[col][row], b.matrix[col][row], alpha_q12);
1693            row += 1;
1694        }
1695        col += 1;
1696    }
1697
1698    GteJointPose {
1699        matrix,
1700        translation: Vec3I16::new(
1701            lerp_i16_q12(a.translation.x, b.translation.x, alpha_q12),
1702            lerp_i16_q12(a.translation.y, b.translation.y, alpha_q12),
1703            lerp_i16_q12(a.translation.z, b.translation.z, alpha_q12),
1704        ),
1705        translation_shift: a.translation_shift,
1706    }
1707}
1708
1709/// Decode one 12-bit Q11 rotation code to a Q3.12 element.
1710///
1711/// Ported from hl-psx's silicon-proven decoder: value = code * 2, with
1712/// the reserved positive-max code 0x7FF decoding to exactly 4096. The decoder
1713/// itself lives in [`decode_q11_element_wide`], including the branchless MIPS
1714/// form that avoids the select branch LLVM would otherwise emit; this is that
1715/// value narrowed, which is exact because the range is `+/-4096`.
1716#[inline(always)]
1717fn decode_q11_element(raw: u16) -> i16 {
1718    decode_q11_element_wide(raw) as i16
1719}
1720
1721/// Q11 rotation decode that keeps its result 32-bit.
1722///
1723/// Identical value to [`decode_q11_element`]: the decoder's range is
1724/// `+/-4096`, so the `i16` narrowing there is exact and re-widening it for
1725/// arithmetic is pure loss. Every use in the v4 path is arithmetic, and on MIPS
1726/// each round trip through `i16` costs an `sll`/`sra` pair.
1727#[inline(always)]
1728fn decode_q11_element_wide(raw: u16) -> i32 {
1729    #[cfg(target_arch = "mips")]
1730    {
1731        let decoded: u32;
1732        // SAFETY: pure register arithmetic: no memory access (`nomem`), no
1733        // stack use (`nostack`), no branches, and `$at` is never named, so
1734        // `.set noat` only silences the assembler. `scratch` is a lateout,
1735        // but the only input lives in `decoded`'s own inlateout register,
1736        // which an output can never share, so the first write to `scratch`
1737        // cannot clobber the input.
1738        unsafe {
1739            core::arch::asm!(
1740                ".set push",
1741                ".set noat",
1742                "xori {scratch}, {decoded}, 0x07ff",
1743                "sltiu {scratch}, {scratch}, 1",
1744                "sll {decoded}, {decoded}, 20",
1745                "sra {decoded}, {decoded}, 19",
1746                "sll {scratch}, {scratch}, 1",
1747                "addu {decoded}, {decoded}, {scratch}",
1748                ".set pop",
1749                decoded = inlateout(reg) raw as u32 => decoded,
1750                scratch = lateout(reg) _,
1751                options(nomem, nostack, preserves_flags),
1752            );
1753        }
1754        decoded as i32
1755    }
1756
1757    #[cfg(not(target_arch = "mips"))]
1758    {
1759        let signed = ((raw << 4) as i16) >> 4;
1760        signed
1761            .wrapping_shl(1)
1762            .wrapping_add((((raw & 0x0FFF) == 0x07FF) as i16) << 1) as i32
1763    }
1764}
1765
1766/// Decode a v3 packed rotation block (nine 12-bit Q11 codes in
1767/// fourteen bytes) into the row-major pose matrix. Byte-wise loads:
1768/// the block is not alignment-guaranteed and the R3000 has no D-cache
1769/// penalty for it.
1770///
1771/// # Safety
1772/// `offset + 14` must be in bounds (v3 records are 20 bytes, so any
1773/// in-bounds record satisfies this).
1774#[inline]
1775unsafe fn read_pose_matrix_q11_unchecked(bytes: &[u8], offset: usize) -> [[i16; 3]; 3] {
1776    let mut flat = [0i16; 9];
1777    let mut pair = 0;
1778    while pair < 4 {
1779        let o = offset + pair * 3;
1780        // SAFETY: `pair < 4`, so the highest byte read is `offset + 11`,
1781        // inside the `offset + 14` the caller guarantees; `u8` reads need no
1782        // alignment.
1783        let packed = unsafe {
1784            (bytes.as_ptr().add(o).read() as u32)
1785                | ((bytes.as_ptr().add(o + 1).read() as u32) << 8)
1786                | ((bytes.as_ptr().add(o + 2).read() as u32) << 16)
1787        };
1788        flat[pair * 2] = decode_q11_element((packed & 0x0FFF) as u16);
1789        flat[pair * 2 + 1] = decode_q11_element(((packed >> 12) & 0x0FFF) as u16);
1790        pair += 1;
1791    }
1792    // SAFETY: bytes `offset + 12` and `offset + 13` are inside the
1793    // `offset + 14` the caller guarantees.
1794    let last = unsafe {
1795        (bytes.as_ptr().add(offset + 12).read() as u16)
1796            | ((bytes.as_ptr().add(offset + 13).read() as u16) << 8)
1797    };
1798    flat[8] = decode_q11_element(last & 0x0FFF);
1799    [
1800        [flat[0], flat[1], flat[2]],
1801        [flat[3], flat[4], flat[5]],
1802        [flat[6], flat[7], flat[8]],
1803    ]
1804}
1805
1806/// Largest magnitude a v4 cross-product accumulator can reach.
1807///
1808/// Every operand is a [`decode_q11_element`] output, which is bounded to
1809/// `+/-4096`, so `a*b - c*d` cannot leave `+/-2*4096*4096`. Two facts follow
1810/// and [`v4_third_basis_q12`] relies on both: the saturating arithmetic the
1811/// original reconstruction used could never fire, and the rounded result
1812/// cannot leave `+/-8192`, which the shared `+/-4096` clamp already covers.
1813const V4_CROSS_LIMIT: i32 = 2 * 4096 * 4096;
1814
1815/// Round one Q3.12 cross-product accumulator, half away from zero.
1816///
1817/// The branchless `(v ^ sign) - sign` form of this was measured and is not
1818/// worth it here: identical values, +0.20% frame cycles and three frames lost,
1819/// because the two arms are small enough that LLVM fills the delay slots.
1820#[inline(always)]
1821fn cross_round_q12(value: i32) -> i32 {
1822    debug_assert!((-V4_CROSS_LIMIT..=V4_CROSS_LIMIT).contains(&value));
1823    if value >= 0 {
1824        (value + (1 << 11)) >> 12
1825    } else {
1826        -((-value + (1 << 11)) >> 12)
1827    }
1828}
1829
1830/// Reconstruct a v4 record's third basis row from the two stored rows and the
1831/// packed correction byte.
1832///
1833/// Bit-identical to the `apply_v4_basis_correction(reconstruct_third_basis_q12(
1834/// first, second), correction_byte)` pair this replaced, over the whole domain
1835/// those two could ever see (see [`V4_CROSS_LIMIT`]); the tests hold both forms
1836/// side by side and sweep it. Three things went away, all of them dead work
1837/// rather than accuracy:
1838///
1839/// * the per-component clamp to the `i16` range, subsumed by the `+/-4096`
1840///   clamp that immediately followed it;
1841/// * the `saturating_add`/`saturating_neg`/`saturating_add` triple in the
1842///   rounding, each of which compiles to an overflow test that can never take
1843///   its other arm at this magnitude;
1844/// * the runtime-indexed array write `third[axis] = ..`, which forced all three
1845///   components onto the stack and back. The R3000A has no data cache, so that
1846///   was six uncached accesses on every decoded record.
1847#[inline(always)]
1848fn v4_third_basis_q12(first: [i32; 3], second: [i32; 3], correction_byte: u8) -> [i32; 3] {
1849    let ([f0, f1, f2], [s0, s1, s2]) = (first, second);
1850    let x = cross_round_q12(f1 * s2 - f2 * s1);
1851    let y = cross_round_q12(f2 * s0 - f0 * s2);
1852    let z = cross_round_q12(f0 * s1 - f1 * s0);
1853    let correction = ((((correction_byte >> 2) << 2) as i8) >> 2) as i32;
1854    // Axis 3 is the "no correction" encoding, matching the `axis < 3` guard the
1855    // array form used.
1856    let (x, y, z) = match correction_byte & 0x03 {
1857        0 => (x + correction, y, z),
1858        1 => (x, y + correction, z),
1859        2 => (x, y, z + correction),
1860        _ => (x, y, z),
1861    };
1862    [
1863        x.clamp(-4096, 4096),
1864        y.clamp(-4096, 4096),
1865        z.clamp(-4096, 4096),
1866    ]
1867}
1868
1869/// Decode a v4 packed rotation block. Its first nine bytes contain six Q11
1870/// codes and byte nine corrects the cross-product reconstruction.
1871///
1872/// # Safety
1873/// `offset + 10` must be in bounds.
1874#[inline]
1875unsafe fn read_pose_matrix_q11_cross_unchecked(bytes: &[u8], offset: usize) -> [[i16; 3]; 3] {
1876    let mut flat = [0i32; 6];
1877    let mut pair = 0usize;
1878    while pair < 3 {
1879        let o = offset + pair * 3;
1880        // SAFETY: `pair < 3`, so the highest byte read is `offset + 8`,
1881        // inside the `offset + 10` the caller guarantees.
1882        let packed = unsafe {
1883            (bytes.as_ptr().add(o).read() as u32)
1884                | ((bytes.as_ptr().add(o + 1).read() as u32) << 8)
1885                | ((bytes.as_ptr().add(o + 2).read() as u32) << 16)
1886        };
1887        flat[pair * 2] = decode_q11_element_wide((packed & 0x0fff) as u16);
1888        flat[pair * 2 + 1] = decode_q11_element_wide(((packed >> 12) & 0x0fff) as u16);
1889        pair += 1;
1890    }
1891    let first = [flat[0], flat[1], flat[2]];
1892    let second = [flat[3], flat[4], flat[5]];
1893    // SAFETY: `offset + 9` is inside the `offset + 10` the caller guarantees.
1894    let correction = unsafe { bytes.as_ptr().add(offset + 9).read() };
1895    let third = v4_third_basis_q12(first, second, correction);
1896    [
1897        narrow_q12_row(first),
1898        narrow_q12_row(second),
1899        narrow_q12_row(third),
1900    ]
1901}
1902
1903/// Decode one word-aligned v4 record with four aligned loads.
1904///
1905/// # Safety
1906/// `offset + 16` must be in bounds and the record must be word aligned.
1907#[inline]
1908unsafe fn read_pose_v4_word_aligned_unchecked(
1909    bytes: &[u8],
1910    offset: usize,
1911) -> ([[i16; 3]; 3], Vec3I16) {
1912    // SAFETY: the caller guarantees `offset + 16` is in bounds and the record
1913    // is word aligned, which is exactly the pointer form's contract.
1914    unsafe { read_pose_v4_word_aligned_ptr(bytes.as_ptr().add(offset)) }
1915}
1916
1917/// Pointer form of [`read_pose_v4_word_aligned_unchecked`], so a caller that
1918/// already holds the record address does not carry a slice length it cannot
1919/// use. Identical decode.
1920///
1921/// # Safety
1922/// `record` must be word aligned with 16 readable bytes.
1923#[inline(always)]
1924unsafe fn read_pose_v4_word_aligned_ptr(record: *const u8) -> ([[i16; 3]; 3], Vec3I16) {
1925    // SAFETY: same contract as this fn: word aligned, 16 readable bytes.
1926    let (rows, translation) = unsafe { read_pose_v4_rows_word_aligned(record) };
1927    (
1928        [
1929            narrow_q12_row(rows[0]),
1930            narrow_q12_row(rows[1]),
1931            narrow_q12_row(rows[2]),
1932        ],
1933        translation,
1934    )
1935}
1936
1937/// Narrow a reconstructed Q3.12 basis row. Every element is already inside
1938/// `+/-4096`, so this is a truncation the store would have done anyway.
1939#[inline(always)]
1940fn narrow_q12_row(row: [i32; 3]) -> [i16; 3] {
1941    [row[0] as i16, row[1] as i16, row[2] as i16]
1942}
1943
1944/// Decode one word-aligned v4 record into three Q3.12 basis rows held as
1945/// `i32`, plus the record's packed translation.
1946///
1947/// This is the shape the arithmetic wants. The narrow
1948/// [`read_pose_v4_word_aligned_ptr`] is this function plus three truncations,
1949/// which the caller's halfword stores absorb for free; the interpolation in
1950/// [`lerp_v4_pair_word_aligned`] skips them entirely and stays 32-bit from the
1951/// packed code all the way to the stored matrix element.
1952///
1953/// # Safety
1954/// `record` must be word aligned with 16 readable bytes.
1955#[inline(always)]
1956unsafe fn read_pose_v4_rows_word_aligned(record: *const u8) -> ([[i32; 3]; 3], Vec3I16) {
1957    debug_assert_eq!(record as usize & 3, 0);
1958    let words = record.cast::<u32>();
1959    // The caller guarantees `record` is word aligned with 16 readable
1960    // bytes, so words 0..=3 are aligned and in bounds.
1961    // SAFETY: word 0, bytes 0..4.
1962    let w0 = u32::from_le(unsafe { words.add(0).read() });
1963    // SAFETY: word 1, bytes 4..8.
1964    let w1 = u32::from_le(unsafe { words.add(1).read() });
1965    // SAFETY: word 2, bytes 8..12.
1966    let w2 = u32::from_le(unsafe { words.add(2).read() });
1967    // SAFETY: word 3, bytes 12..16.
1968    let w3 = u32::from_le(unsafe { words.add(3).read() });
1969
1970    let p0 = w0 & 0x00ff_ffff;
1971    let p1 = (w0 >> 24) | ((w1 & 0x0000_ffff) << 8);
1972    let p2 = (w1 >> 16) | ((w2 & 0x0000_00ff) << 16);
1973    let first = [
1974        decode_q11_element_wide((p0 & 0x0fff) as u16),
1975        decode_q11_element_wide(((p0 >> 12) & 0x0fff) as u16),
1976        decode_q11_element_wide((p1 & 0x0fff) as u16),
1977    ];
1978    let second = [
1979        decode_q11_element_wide(((p1 >> 12) & 0x0fff) as u16),
1980        decode_q11_element_wide((p2 & 0x0fff) as u16),
1981        decode_q11_element_wide(((p2 >> 12) & 0x0fff) as u16),
1982    ];
1983    let third = v4_third_basis_q12(first, second, ((w2 >> 8) & 0xff) as u8);
1984    let translation = Vec3I16::new(
1985        (w2 >> 16) as u16 as i16,
1986        w3 as u16 as i16,
1987        (w3 >> 16) as u16 as i16,
1988    );
1989    ([first, second, third], translation)
1990}
1991
1992/// Decode both frames of one word-aligned v4 joint record pair and interpolate
1993/// them, in a single body.
1994///
1995/// Bit-identical to decoding each record with [`Animation::pose_v4_unchecked`]
1996/// and feeding the two [`JointPose`] values to [`lerp_pose_q12`]; only the call
1997/// shape differs. Fusing them is what keeps the two decoded poses in registers
1998/// instead of round-tripping 64 bytes through uncached main RAM, which is the
1999/// dominant cost of a skinned joint on the R3000A.
2000///
2001/// # Safety
2002/// `a_record` and `b_record` must each be word aligned with 16 readable bytes.
2003#[inline]
2004unsafe fn lerp_v4_pair_word_aligned(
2005    a_record: *const u8,
2006    b_record: *const u8,
2007    translation_shift: u8,
2008    alpha_q12: u16,
2009) -> JointPose {
2010    // SAFETY: the caller guarantees `a_record` is word aligned with 16
2011    // readable bytes.
2012    let (a_rows, a_packed) = unsafe { read_pose_v4_rows_word_aligned(a_record) };
2013    // SAFETY: likewise for `b_record`.
2014    let (b_rows, b_packed) = unsafe { read_pose_v4_rows_word_aligned(b_record) };
2015
2016    let mut matrix = [[0i16; 3]; 3];
2017    let mut col = 0;
2018    while col < 3 {
2019        let mut row = 0;
2020        while row < 3 {
2021            matrix[col][row] = lerp_q12_wide_to_i16(a_rows[col][row], b_rows[col][row], alpha_q12);
2022            row += 1;
2023        }
2024        col += 1;
2025    }
2026
2027    JointPose {
2028        matrix,
2029        translation: Vec3I32::new(
2030            lerp_packed_translation_q12(a_packed.x, b_packed.x, translation_shift, alpha_q12),
2031            lerp_packed_translation_q12(a_packed.y, b_packed.y, translation_shift, alpha_q12),
2032            lerp_packed_translation_q12(a_packed.z, b_packed.z, translation_shift, alpha_q12),
2033        ),
2034    }
2035}
2036
2037/// Interpolate one shift-packed translation component.
2038///
2039/// Bit-identical to `lerp_i32_q12(decode_packed_translation(a, shift),
2040/// decode_packed_translation(b, shift), alpha_q12)` over the whole domain that
2041/// call can reach, and the tests sweep it against exactly that expression.
2042///
2043/// The generic [`lerp_i32_q12`] has to saturate, because v1/v2 records store
2044/// raw `i32` translations of unknown magnitude. A packed record cannot: both
2045/// endpoints are `i16`, and the parser rejects a translation shift above 15, so
2046/// every endpoint fits in `+/-2^30`, every delta in `+/-(2^31 - 2^15)`, and
2047/// every partial product below `i32::MAX`. That makes all four saturating steps
2048/// unreachable, and dropping them matters here because `saturating_mul` on this
2049/// CPU is a `mult` plus a high-word comparison and branch, three times per
2050/// joint.
2051#[inline(always)]
2052fn lerp_packed_translation_q12(a: i16, b: i16, shift: u8, alpha_q12: u16) -> i32 {
2053    debug_assert!(shift <= 15);
2054    debug_assert!(alpha_q12 < 4096);
2055    let base = (a as i32) << shift;
2056    let delta = ((b as i32) << shift) - base;
2057    // Split the Q12 scale exactly as `scale_i32_q12` does: the product of a
2058    // full delta and alpha does not fit in 32 bits, the two halves do.
2059    let alpha = alpha_q12 as i32;
2060    let whole = delta >> 12;
2061    let frac = delta - (whole << 12);
2062    base + whole * alpha + ((frac * alpha) >> 12)
2063}
2064
2065/// Decode one word-aligned v3 pose record with exactly five aligned loads.
2066///
2067/// The rotation occupies bytes 0..14 and the three translations occupy
2068/// bytes 14..20, so words three and four naturally carry both the final
2069/// rotation code and every translation halfword. The returned values are
2070/// identical to [`read_pose_matrix_q11_unchecked`] plus three little-endian
2071/// `i16` reads; only the load shape differs.
2072///
2073/// # Safety
2074/// `offset + 20` must be in bounds and `bytes.as_ptr().add(offset)` must be
2075/// aligned for `u32`.
2076#[inline]
2077unsafe fn read_pose_v3_word_aligned_unchecked(
2078    bytes: &[u8],
2079    offset: usize,
2080) -> ([[i16; 3]; 3], Vec3I16) {
2081    // SAFETY: the caller guarantees `offset + 20 <= bytes.len()`, so
2082    // `offset` is within the allocation.
2083    let record = unsafe { bytes.as_ptr().add(offset) };
2084    debug_assert_eq!(record as usize & 3, 0);
2085    let words = record.cast::<u32>();
2086    // The caller guarantees the record is u32 aligned with 20 readable
2087    // bytes, so words 0..=4 are aligned and in bounds.
2088    // SAFETY: word 0, bytes 0..4 of the record.
2089    let w0 = u32::from_le(unsafe { words.add(0).read() });
2090    // SAFETY: word 1, bytes 4..8 of the record.
2091    let w1 = u32::from_le(unsafe { words.add(1).read() });
2092    // SAFETY: word 2, bytes 8..12 of the record.
2093    let w2 = u32::from_le(unsafe { words.add(2).read() });
2094    // SAFETY: word 3, bytes 12..16 of the record.
2095    let w3 = u32::from_le(unsafe { words.add(3).read() });
2096    // SAFETY: word 4, bytes 16..20 of the record.
2097    let w4 = u32::from_le(unsafe { words.add(4).read() });
2098
2099    let p0 = w0 & 0x00ff_ffff;
2100    let p1 = (w0 >> 24) | ((w1 & 0x0000_ffff) << 8);
2101    let p2 = (w1 >> 16) | ((w2 & 0x0000_00ff) << 16);
2102    let p3 = (w2 >> 8) & 0x00ff_ffff;
2103    let matrix = [
2104        [
2105            decode_q11_element((p0 & 0x0fff) as u16),
2106            decode_q11_element(((p0 >> 12) & 0x0fff) as u16),
2107            decode_q11_element((p1 & 0x0fff) as u16),
2108        ],
2109        [
2110            decode_q11_element(((p1 >> 12) & 0x0fff) as u16),
2111            decode_q11_element((p2 & 0x0fff) as u16),
2112            decode_q11_element(((p2 >> 12) & 0x0fff) as u16),
2113        ],
2114        [
2115            decode_q11_element((p3 & 0x0fff) as u16),
2116            decode_q11_element(((p3 >> 12) & 0x0fff) as u16),
2117            decode_q11_element((w3 & 0x0fff) as u16),
2118        ],
2119    ];
2120    let translation = Vec3I16::new(
2121        (w3 >> 16) as u16 as i16,
2122        w4 as u16 as i16,
2123        (w4 >> 16) as u16 as i16,
2124    );
2125    (matrix, translation)
2126}
2127
2128fn read_pose_matrix(bytes: &[u8]) -> [[i16; 3]; 3] {
2129    [
2130        [read_i16(bytes, 0), read_i16(bytes, 2), read_i16(bytes, 4)],
2131        [read_i16(bytes, 6), read_i16(bytes, 8), read_i16(bytes, 10)],
2132        [
2133            read_i16(bytes, 12),
2134            read_i16(bytes, 14),
2135            read_i16(bytes, 16),
2136        ],
2137    ]
2138}
2139
2140/// Byte-wise read of an 18-byte row-major `i16` pose matrix.
2141///
2142/// # Safety
2143/// `offset + 18` must be `<= bytes.len()`.
2144#[inline]
2145unsafe fn read_pose_matrix_unchecked(bytes: &[u8], offset: usize) -> [[i16; 3]; 3] {
2146    [
2147        [
2148            // SAFETY: bytes `offset..offset + 2`, inside the caller's `offset + 18`.
2149            unsafe { read_i16_unchecked(bytes, offset) },
2150            // SAFETY: bytes `offset + 2..offset + 4`, inside the caller's `offset + 18`.
2151            unsafe { read_i16_unchecked(bytes, offset + 2) },
2152            // SAFETY: bytes `offset + 4..offset + 6`, inside the caller's `offset + 18`.
2153            unsafe { read_i16_unchecked(bytes, offset + 4) },
2154        ],
2155        [
2156            // SAFETY: bytes `offset + 6..offset + 8`, inside the caller's `offset + 18`.
2157            unsafe { read_i16_unchecked(bytes, offset + 6) },
2158            // SAFETY: bytes `offset + 8..offset + 10`, inside the caller's `offset + 18`.
2159            unsafe { read_i16_unchecked(bytes, offset + 8) },
2160            // SAFETY: bytes `offset + 10..offset + 12`, inside the caller's `offset + 18`.
2161            unsafe { read_i16_unchecked(bytes, offset + 10) },
2162        ],
2163        [
2164            // SAFETY: bytes `offset + 12..offset + 14`, inside the caller's `offset + 18`.
2165            unsafe { read_i16_unchecked(bytes, offset + 12) },
2166            // SAFETY: bytes `offset + 14..offset + 16`, inside the caller's `offset + 18`.
2167            unsafe { read_i16_unchecked(bytes, offset + 14) },
2168            // SAFETY: bytes `offset + 16..offset + 18`, inside the caller's `offset + 18`.
2169            unsafe { read_i16_unchecked(bytes, offset + 16) },
2170        ],
2171    ]
2172}
2173
2174/// Halfword-load read of an 18-byte row-major `i16` pose matrix.
2175///
2176/// # Safety
2177/// `offset + 18` must be `<= bytes.len()` and `bytes.as_ptr().add(offset)`
2178/// must be 2-byte aligned.
2179#[inline]
2180unsafe fn read_pose_matrix_aligned_unchecked(bytes: &[u8], offset: usize) -> [[i16; 3]; 3] {
2181    [
2182        [
2183            // SAFETY: bytes `offset..offset + 2`, in bounds and 2-byte aligned.
2184            unsafe { read_i16_aligned_unchecked(bytes, offset) },
2185            // SAFETY: bytes `offset + 2..offset + 4`, in bounds and 2-byte aligned.
2186            unsafe { read_i16_aligned_unchecked(bytes, offset + 2) },
2187            // SAFETY: bytes `offset + 4..offset + 6`, in bounds and 2-byte aligned.
2188            unsafe { read_i16_aligned_unchecked(bytes, offset + 4) },
2189        ],
2190        [
2191            // SAFETY: bytes `offset + 6..offset + 8`, in bounds and 2-byte aligned.
2192            unsafe { read_i16_aligned_unchecked(bytes, offset + 6) },
2193            // SAFETY: bytes `offset + 8..offset + 10`, in bounds and 2-byte aligned.
2194            unsafe { read_i16_aligned_unchecked(bytes, offset + 8) },
2195            // SAFETY: bytes `offset + 10..offset + 12`, in bounds and 2-byte aligned.
2196            unsafe { read_i16_aligned_unchecked(bytes, offset + 10) },
2197        ],
2198        [
2199            // SAFETY: bytes `offset + 12..offset + 14`, in bounds and 2-byte aligned.
2200            unsafe { read_i16_aligned_unchecked(bytes, offset + 12) },
2201            // SAFETY: bytes `offset + 14..offset + 16`, in bounds and 2-byte aligned.
2202            unsafe { read_i16_aligned_unchecked(bytes, offset + 14) },
2203            // SAFETY: bytes `offset + 16..offset + 18`, in bounds and 2-byte aligned.
2204            unsafe { read_i16_aligned_unchecked(bytes, offset + 16) },
2205        ],
2206    ]
2207}
2208
2209#[inline]
2210fn decode_packed_translation(value: i16, shift: u8) -> i32 {
2211    debug_assert!(shift <= 15);
2212    (value as i32) * (1i32 << shift)
2213}
2214
2215/// Interpolate two Q3.12 elements that are already 32-bit.
2216///
2217/// Exactly [`lerp_i16_q12`] with its two sign extensions removed: that function
2218/// widens both endpoints to `i32` before doing anything, so feeding it values
2219/// that never left `i32` computes the identical expression.
2220#[inline(always)]
2221fn lerp_q12_wide_to_i16(a: i32, b: i32, alpha_q12: u16) -> i16 {
2222    debug_assert!(alpha_q12 < 4096);
2223    (a + (((b - a) * alpha_q12 as i32) >> 12)) as i16
2224}
2225
2226#[inline]
2227fn lerp_i16_q12(a: i16, b: i16, alpha_q12: u16) -> i16 {
2228    debug_assert!(alpha_q12 < 4096);
2229    let value = a as i32 + (((b as i32 - a as i32) * alpha_q12 as i32) >> 12);
2230    value as i16
2231}
2232
2233#[inline]
2234fn lerp_i32_q12(a: i32, b: i32, alpha_q12: u16) -> i32 {
2235    let delta = b.saturating_sub(a);
2236    a.saturating_add(scale_i32_q12(delta, alpha_q12 as i32))
2237}
2238
2239#[inline]
2240fn scale_i32_q12(value: i32, scale_q12: i32) -> i32 {
2241    let whole = value >> 12;
2242    let frac = value - (whole << 12);
2243    whole.saturating_mul(scale_q12) + ((frac * scale_q12) >> 12)
2244}
2245
2246/// A parsed 2D texture backed by slices into the caller's cooked
2247/// blob. Pixel data is already packed into the halfword-nibble
2248/// layout the PSX GPU reads; the CLUT (if any) is a slice of
2249/// RGB555 halfwords.
2250///
2251/// Construct by `Texture::from_bytes(&blob)`; upload to VRAM via
2252/// [`Texture::upload`], which returns the matching `TexturePage` + `Clut`
2253/// handles ready to feed into primitive constructors.
2254#[derive(Copy, Clone, Debug)]
2255pub struct Texture<'a> {
2256    /// Packed pixel halfwords -- 4 texels per u16 at 4bpp,
2257    /// 2 at 8bpp, 1 Color555 at 15bpp.
2258    pixel_data: &'a [u8],
2259    /// CLUT halfwords, or empty for 15bpp.
2260    clut_data: &'a [u8],
2261    width_px: u16,
2262    height_px: u16,
2263    depth: psxed_format::texture::Depth,
2264    clut_entries: u16,
2265    flags: u16,
2266}
2267
2268impl<'a> Texture<'a> {
2269    /// Parse a cooked `.psxt` blob. Returns a `Texture` view that
2270    /// borrows into `bytes`. Cheap -- header decode + two slice
2271    /// computations.
2272    pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, ParseError> {
2273        use psxed_format::texture::{Depth, TextureHeader, MAGIC, VERSION};
2274
2275        // AssetHeader.
2276        if bytes.len() < psxed_format::AssetHeader::SIZE {
2277            return Err(ParseError::Truncated);
2278        }
2279        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
2280        if magic != MAGIC {
2281            return Err(ParseError::WrongMagic);
2282        }
2283        let version = u16::from_le_bytes([bytes[4], bytes[5]]);
2284        if version != VERSION {
2285            return Err(ParseError::UnsupportedVersion(version));
2286        }
2287        let flags = u16::from_le_bytes([bytes[6], bytes[7]]);
2288        let payload_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
2289        let payload_start = psxed_format::AssetHeader::SIZE;
2290        let actual_payload = bytes.len().saturating_sub(payload_start);
2291        if (payload_len as usize) != actual_payload {
2292            return Err(ParseError::InvalidPayloadLen {
2293                declared: payload_len,
2294                actual: actual_payload,
2295            });
2296        }
2297
2298        // TextureHeader.
2299        if actual_payload < TextureHeader::SIZE {
2300            return Err(ParseError::Truncated);
2301        }
2302        let th = &bytes[payload_start..];
2303        let depth = Depth::from_byte(th[0]).ok_or(ParseError::TableOverflow)?;
2304        // th[1] is _pad; skip.
2305        let width_px = u16::from_le_bytes([th[2], th[3]]);
2306        let height_px = u16::from_le_bytes([th[4], th[5]]);
2307        let clut_entries = u16::from_le_bytes([th[6], th[7]]);
2308        let pixel_bytes = u32::from_le_bytes([th[8], th[9], th[10], th[11]]) as usize;
2309        let clut_bytes = u32::from_le_bytes([th[12], th[13], th[14], th[15]]) as usize;
2310
2311        // `take_table` adds with `checked_add`: on the 32-bit console a header claiming
2312        // `0xFFFF_FFF0` pixel bytes wrapped `off + pixel_bytes` past the length test and
2313        // panicked on the slice.
2314        let mut off = payload_start + TextureHeader::SIZE;
2315        let pixel_data = take_table(bytes, &mut off, pixel_bytes)?;
2316        let clut_data = take_table(bytes, &mut off, clut_bytes)?;
2317
2318        Ok(Self {
2319            pixel_data,
2320            clut_data,
2321            width_px,
2322            height_px,
2323            depth,
2324            clut_entries,
2325            flags,
2326        })
2327    }
2328
2329    /// Width in texels.
2330    #[inline]
2331    pub fn width(&self) -> u16 {
2332        self.width_px
2333    }
2334
2335    /// Height in texels.
2336    #[inline]
2337    pub fn height(&self) -> u16 {
2338        self.height_px
2339    }
2340
2341    /// Colour depth.
2342    #[inline]
2343    pub fn depth(&self) -> psxed_format::texture::Depth {
2344        self.depth
2345    }
2346
2347    /// Number of CLUT entries in the blob. Normal textures use 16
2348    /// for 4bpp, 256 for 8bpp, or 0 for 15bpp; specialized 4bpp
2349    /// assets may concatenate multiple 16-entry CLUT rows.
2350    #[inline]
2351    pub fn clut_entries(&self) -> u16 {
2352        self.clut_entries
2353    }
2354
2355    /// Texture feature flags from the shared asset header.
2356    #[inline]
2357    pub fn flags(&self) -> u16 {
2358        self.flags
2359    }
2360
2361    /// True when indexed palette entry 0 should remain transparent.
2362    #[inline]
2363    pub fn is_index_zero_transparent(&self) -> bool {
2364        self.flags & psxed_format::texture::flags::INDEX_ZERO_TRANSPARENT != 0
2365    }
2366
2367    /// Raw packed pixel halfwords, as bytes. Suitable for
2368    /// halfword-level DMA upload; caller pairs this with a `VramRect`
2369    /// describing the *halfword footprint*, not the texel width.
2370    #[inline]
2371    pub fn pixel_bytes(&self) -> &'a [u8] {
2372        self.pixel_data
2373    }
2374
2375    /// Raw CLUT halfwords, as bytes. Empty for 15bpp.
2376    #[inline]
2377    pub fn clut_bytes(&self) -> &'a [u8] {
2378        self.clut_data
2379    }
2380
2381    /// Halfwords-per-row at this texture's depth (rows padded up to
2382    /// a full halfword). Needed when computing the VRAM rect for
2383    /// upload: VRAM measures in halfwords regardless of the texel
2384    /// depth the GPU will fetch them at.
2385    #[inline]
2386    pub fn halfwords_per_row(&self) -> u16 {
2387        psxed_format::texture::TextureHeader::halfwords_per_row(self.depth, self.width_px)
2388    }
2389}
2390
2391/// A parsed `.psxw` grid-world backed by slices into the cooked blob.
2392///
2393/// This is the binary runtime format; engine code wraps it as
2394/// `psx_engine::RuntimeRoom`. It keeps parsing zero-copy and lets engine
2395/// code pull sectors and walls by value as needed.
2396#[derive(Copy, Clone, Debug)]
2397pub struct World<'a> {
2398    sectors: &'a [u8],
2399    walls: &'a [u8],
2400    horizontal_overrides: &'a [u8],
2401    surface_lights: &'a [u8],
2402    sector_record_size: usize,
2403    wall_record_size: usize,
2404    horizontal_override_record_size: usize,
2405    width: u16,
2406    depth: u16,
2407    sector_size: i32,
2408    material_count: u16,
2409    wall_count: u16,
2410    horizontal_override_count: u16,
2411    surface_light_count: u16,
2412    ambient_color: [u8; 3],
2413    flags: u8,
2414    static_vertex_lighting: bool,
2415}
2416
2417/// Four PS1 UV coordinates for one world quad.
2418#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2419pub struct WorldQuadUvs {
2420    corners: [(u8, u8); 4],
2421}
2422
2423impl WorldQuadUvs {
2424    /// Build a quad UV record from face-corner coordinates.
2425    pub const fn new(corners: [(u8, u8); 4]) -> Self {
2426        Self { corners }
2427    }
2428
2429    /// Return UVs as face-corner coordinates.
2430    pub const fn corners(self) -> [(u8, u8); 4] {
2431        self.corners
2432    }
2433}
2434
2435/// Four RGB vertex colours for one world quad.
2436#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2437pub struct WorldSurfaceLight {
2438    vertex_rgb: [[u8; 3]; 4],
2439}
2440
2441impl WorldSurfaceLight {
2442    /// Full-bright neutral lighting for legacy/unlit rooms.
2443    pub const fn white() -> Self {
2444        Self {
2445            vertex_rgb: [[255, 255, 255]; 4],
2446        }
2447    }
2448
2449    /// Build from per-corner RGB values.
2450    pub const fn new(vertex_rgb: [[u8; 3]; 4]) -> Self {
2451        Self { vertex_rgb }
2452    }
2453
2454    /// Return RGB values in face-corner order.
2455    pub const fn vertex_rgb(self) -> [[u8; 3]; 4] {
2456        self.vertex_rgb
2457    }
2458}
2459
2460const WORLD_V1_SECTOR_RECORD_SIZE: usize = 44;
2461const WORLD_V1_WALL_RECORD_SIZE: usize = 24;
2462const WORLD_V2_SECTOR_RECORD_SIZE: usize = 60;
2463const WORLD_V2_WALL_RECORD_SIZE: usize = 32;
2464const WORLD_V3_HEADER_SIZE: usize = 20;
2465const WORLD_V3_SECTOR_RECORD_SIZE: usize = 60;
2466const WORLD_V3_WALL_RECORD_SIZE: usize = 32;
2467const WORLD_V4_HORIZONTAL_OVERRIDE_RECORD_SIZE: usize = 24;
2468
2469impl<'a> World<'a> {
2470    /// Parse a cooked `.psxw` blob.
2471    pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, ParseError> {
2472        use psxed_format::world::{
2473            WorldHeader, MAGIC, VERSION, VERSION_V1, VERSION_V2, VERSION_V3, VERSION_V4,
2474        };
2475
2476        if bytes.len() < psxed_format::AssetHeader::SIZE {
2477            return Err(ParseError::Truncated);
2478        }
2479        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
2480        if magic != MAGIC {
2481            return Err(ParseError::WrongMagic);
2482        }
2483        let version = u16::from_le_bytes([bytes[4], bytes[5]]);
2484        if version != VERSION
2485            && version != VERSION_V3
2486            && version != VERSION_V4
2487            && version != VERSION_V2
2488            && version != VERSION_V1
2489        {
2490            return Err(ParseError::UnsupportedVersion(version));
2491        }
2492        let (world_header_size, sector_record_size, wall_record_size) = match version {
2493            VERSION_V1 => (
2494                WORLD_V3_HEADER_SIZE,
2495                WORLD_V1_SECTOR_RECORD_SIZE,
2496                WORLD_V1_WALL_RECORD_SIZE,
2497            ),
2498            VERSION_V2 => (
2499                WORLD_V3_HEADER_SIZE,
2500                WORLD_V2_SECTOR_RECORD_SIZE,
2501                WORLD_V2_WALL_RECORD_SIZE,
2502            ),
2503            VERSION_V3 => (
2504                WORLD_V3_HEADER_SIZE,
2505                WORLD_V3_SECTOR_RECORD_SIZE,
2506                WORLD_V3_WALL_RECORD_SIZE,
2507            ),
2508            VERSION_V4 => (
2509                WorldHeader::SIZE,
2510                psxed_format::world::SectorRecord::SIZE,
2511                psxed_format::world::WallRecord::SIZE,
2512            ),
2513            _ => (
2514                WorldHeader::SIZE,
2515                psxed_format::world::SectorRecord::SIZE,
2516                psxed_format::world::WallRecord::SIZE,
2517            ),
2518        };
2519        let payload_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
2520        let payload_start = psxed_format::AssetHeader::SIZE;
2521        let actual_payload = bytes.len().saturating_sub(payload_start);
2522        if payload_len as usize != actual_payload {
2523            return Err(ParseError::InvalidPayloadLen {
2524                declared: payload_len,
2525                actual: actual_payload,
2526            });
2527        }
2528        if actual_payload < world_header_size {
2529            return Err(ParseError::Truncated);
2530        }
2531
2532        let wh = &bytes[payload_start..payload_start + world_header_size];
2533        let width = read_u16(wh, 0);
2534        let depth = read_u16(wh, 2);
2535        let sector_size = read_i32(wh, 4);
2536        let sector_count = read_u16(wh, 8);
2537        let material_count = read_u16(wh, 10);
2538        let wall_count = read_u16(wh, 12);
2539        let ambient_color = [wh[14], wh[15], wh[16]];
2540        let flags = wh[17];
2541        let surface_light_count =
2542            if version == VERSION || version == VERSION_V4 || version == VERSION_V3 {
2543                read_u16(wh, 18)
2544            } else {
2545                0
2546            };
2547        let horizontal_override_count = if version == VERSION || version == VERSION_V4 {
2548            read_u16(wh, 20)
2549        } else {
2550            0
2551        };
2552        let static_vertex_lighting =
2553            (version == VERSION || version == VERSION_V4 || version == VERSION_V3)
2554                && flags & psxed_format::world::world_flags::STATIC_VERTEX_LIGHTING != 0;
2555
2556        let expected_sectors = (width as usize)
2557            .checked_mul(depth as usize)
2558            .ok_or(ParseError::InvalidWorldLayout)?;
2559        if sector_count as usize != expected_sectors {
2560            return Err(ParseError::InvalidWorldLayout);
2561        }
2562        if static_vertex_lighting {
2563            let expected_surface_lights = sector_count
2564                .checked_mul(2)
2565                .and_then(|count| count.checked_add(wall_count))
2566                .ok_or(ParseError::InvalidWorldLayout)?;
2567            if surface_light_count != expected_surface_lights {
2568                return Err(ParseError::InvalidWorldLayout);
2569            }
2570        } else if surface_light_count != 0 {
2571            return Err(ParseError::InvalidWorldLayout);
2572        }
2573
2574        let mut off = payload_start + world_header_size;
2575        let sector_bytes = (sector_count as usize)
2576            .checked_mul(sector_record_size)
2577            .ok_or(ParseError::TableOverflow)?;
2578        if off + sector_bytes > bytes.len() {
2579            return Err(ParseError::TableOverflow);
2580        }
2581        let sectors = &bytes[off..off + sector_bytes];
2582        off += sector_bytes;
2583
2584        let wall_bytes = (wall_count as usize)
2585            .checked_mul(wall_record_size)
2586            .ok_or(ParseError::TableOverflow)?;
2587        if off + wall_bytes > bytes.len() {
2588            return Err(ParseError::TableOverflow);
2589        }
2590        let walls = &bytes[off..off + wall_bytes];
2591        off += wall_bytes;
2592        let horizontal_override_record_size = if version == VERSION_V4 {
2593            WORLD_V4_HORIZONTAL_OVERRIDE_RECORD_SIZE
2594        } else {
2595            psxed_format::world::HorizontalOverrideRecord::SIZE
2596        };
2597        let horizontal_override_bytes = (horizontal_override_count as usize)
2598            .checked_mul(horizontal_override_record_size)
2599            .ok_or(ParseError::TableOverflow)?;
2600        if off + horizontal_override_bytes > bytes.len() {
2601            return Err(ParseError::TableOverflow);
2602        }
2603        let horizontal_overrides = &bytes[off..off + horizontal_override_bytes];
2604        off += horizontal_override_bytes;
2605        let surface_light_bytes = (surface_light_count as usize)
2606            .checked_mul(psxed_format::world::SurfaceLightRecord::SIZE)
2607            .ok_or(ParseError::TableOverflow)?;
2608        if off + surface_light_bytes > bytes.len() {
2609            return Err(ParseError::TableOverflow);
2610        }
2611        let surface_lights = &bytes[off..off + surface_light_bytes];
2612        off += surface_light_bytes;
2613        if off != bytes.len() {
2614            return Err(ParseError::InvalidWorldLayout);
2615        }
2616
2617        validate_sector_wall_ranges(sectors, sector_record_size, wall_count)?;
2618        validate_horizontal_overrides(
2619            horizontal_overrides,
2620            horizontal_override_count,
2621            sector_count,
2622            horizontal_override_record_size,
2623        )?;
2624
2625        Ok(Self {
2626            sectors,
2627            walls,
2628            horizontal_overrides,
2629            surface_lights,
2630            sector_record_size,
2631            wall_record_size,
2632            horizontal_override_record_size,
2633            width,
2634            depth,
2635            sector_size,
2636            material_count,
2637            wall_count,
2638            horizontal_override_count,
2639            surface_light_count,
2640            ambient_color,
2641            flags,
2642            static_vertex_lighting,
2643        })
2644    }
2645
2646    /// Width in grid sectors.
2647    #[inline]
2648    pub fn width(&self) -> u16 {
2649        self.width
2650    }
2651
2652    /// Depth in grid sectors.
2653    #[inline]
2654    pub fn depth(&self) -> u16 {
2655        self.depth
2656    }
2657
2658    /// Engine units per sector.
2659    #[inline]
2660    pub fn sector_size(&self) -> i32 {
2661        self.sector_size
2662    }
2663
2664    /// Number of material slots referenced by the world.
2665    #[inline]
2666    pub fn material_count(&self) -> u16 {
2667        self.material_count
2668    }
2669
2670    /// Number of wall records in the world.
2671    #[inline]
2672    pub fn wall_count(&self) -> u16 {
2673        self.wall_count
2674    }
2675
2676    /// Number of horizontal override records in the world.
2677    #[inline]
2678    pub fn horizontal_override_count(&self) -> u16 {
2679        self.horizontal_override_count
2680    }
2681
2682    /// Number of appended static surface-light records.
2683    #[inline]
2684    pub fn surface_light_count(&self) -> u16 {
2685        self.surface_light_count
2686    }
2687
2688    /// Ambient RGB color.
2689    #[inline]
2690    pub fn ambient_color(&self) -> [u8; 3] {
2691        self.ambient_color
2692    }
2693
2694    /// Whether fog/depth cue is enabled for this world.
2695    #[inline]
2696    pub fn is_fog_enabled(&self) -> bool {
2697        self.flags & psxed_format::world::world_flags::FOG_ENABLED != 0
2698    }
2699
2700    /// Whether face records carry baked static vertex lighting.
2701    #[inline]
2702    pub fn has_static_vertex_lighting(&self) -> bool {
2703        self.static_vertex_lighting
2704    }
2705
2706    /// Sector at a coordinate, returning `None` for empty cells or out of range.
2707    pub fn sector(&self, x: u16, z: u16) -> Option<WorldSector> {
2708        if x >= self.width || z >= self.depth {
2709            return None;
2710        }
2711        let index = x as usize * self.depth as usize + z as usize;
2712        let sector = self.sector_record(index)?;
2713        if sector.has_geometry() {
2714            Some(sector)
2715        } else {
2716            None
2717        }
2718    }
2719
2720    /// Sector at a coordinate without applying horizontal override
2721    /// side-table records. Collision and wall-only queries use this
2722    /// to avoid scanning render-only per-triangle override data.
2723    pub fn sector_without_horizontal_overrides(&self, x: u16, z: u16) -> Option<WorldSector> {
2724        if x >= self.width || z >= self.depth {
2725            return None;
2726        }
2727        let index = x as usize * self.depth as usize + z as usize;
2728        let sector = self.sector_record_without_horizontal_overrides(index)?;
2729        if sector.has_geometry() {
2730            Some(sector)
2731        } else {
2732            None
2733        }
2734    }
2735
2736    /// Minimal collision-probe sector header at a coordinate. This
2737    /// skips horizontal override decoding and only exposes floor
2738    /// presence plus the wall range.
2739    pub fn sector_collision_probe(&self, x: u16, z: u16) -> Option<WorldSectorCollisionProbe> {
2740        if x >= self.width || z >= self.depth {
2741            return None;
2742        }
2743        let index = x as usize * self.depth as usize + z as usize;
2744        let sector = self.sector_collision_probe_record(index)?;
2745        if sector.has_geometry() {
2746            Some(sector)
2747        } else {
2748            None
2749        }
2750    }
2751
2752    /// Decode only the selected floor triangle needed by a collision query.
2753    ///
2754    /// Unlike [`Self::sector`], this does not materialize render materials,
2755    /// UVs, ceiling data, or the unselected triangle.
2756    pub fn sector_floor_collision(
2757        &self,
2758        x: u16,
2759        z: u16,
2760        local_x: i32,
2761        local_z: i32,
2762        sector_size: i32,
2763    ) -> Option<WorldSectorFloorCollision> {
2764        if x >= self.width || z >= self.depth || sector_size <= 0 {
2765            return None;
2766        }
2767        let index = x as usize * self.depth as usize + z as usize;
2768        let size = self.sector_record_size;
2769        let base = index.checked_mul(size)?;
2770        let end = base.checked_add(size)?;
2771        let bytes = self.sectors.get(base..end)?;
2772        let flags = bytes[0];
2773        if flags & psxed_format::world::sector_flags::HAS_FLOOR == 0 {
2774            return None;
2775        }
2776
2777        let split = bytes[1];
2778        let triangle =
2779            world_topology::horizontal_triangle_at_local(split, local_x, local_z, sector_size);
2780        let default_flags = default_horizontal_flags(
2781            true,
2782            flags & psxed_format::world::sector_flags::FLOOR_WALKABLE != 0,
2783        );
2784        let sector_index = u16::try_from(index).ok()?;
2785        let (triangle_flags, override_heights) = self
2786            .horizontal_collision_override(
2787                sector_index,
2788                psxed_format::world::horizontal_surface::FLOOR,
2789                triangle,
2790            )
2791            .unwrap_or((default_flags, None));
2792        if !horizontal_triangle_present(triangle_flags, triangle) {
2793            return None;
2794        }
2795
2796        let floor_heights = read_i32x4(bytes, 12);
2797        let triangle_heights = override_heights
2798            .unwrap_or_else(|| horizontal_triangle_heights(floor_heights, split, triangle));
2799        Some(WorldSectorFloorCollision {
2800            split,
2801            triangle: triangle as u8,
2802            walkable: horizontal_triangle_walkable(triangle_flags, triangle),
2803            floor_heights,
2804            triangle_heights,
2805        })
2806    }
2807
2808    /// Sector record by flat `[x * depth + z]` index, including empty cells.
2809    pub fn sector_record(&self, index: usize) -> Option<WorldSector> {
2810        let size = self.sector_record_size;
2811        let base = index.checked_mul(size)?;
2812        let end = base.checked_add(size)?;
2813        let bytes = self.sectors.get(base..end)?;
2814        let sector_index = if index <= u16::MAX as usize {
2815            index as u16
2816        } else {
2817            return None;
2818        };
2819        let floor_override =
2820            self.horizontal_override(sector_index, psxed_format::world::horizontal_surface::FLOOR);
2821        let ceiling_override = self.horizontal_override(
2822            sector_index,
2823            psxed_format::world::horizontal_surface::CEILING,
2824        );
2825        Some(WorldSector::decode(bytes, floor_override, ceiling_override))
2826    }
2827
2828    fn sector_record_without_horizontal_overrides(&self, index: usize) -> Option<WorldSector> {
2829        let size = self.sector_record_size;
2830        let base = index.checked_mul(size)?;
2831        let end = base.checked_add(size)?;
2832        let bytes = self.sectors.get(base..end)?;
2833        if index > u16::MAX as usize {
2834            return None;
2835        }
2836        Some(WorldSector::decode(bytes, None, None))
2837    }
2838
2839    fn sector_collision_probe_record(&self, index: usize) -> Option<WorldSectorCollisionProbe> {
2840        let size = self.sector_record_size;
2841        let base = index.checked_mul(size)?;
2842        let end = base.checked_add(size)?;
2843        let bytes = self.sectors.get(base..end)?;
2844        if index > u16::MAX as usize {
2845            return None;
2846        }
2847        Some(WorldSectorCollisionProbe {
2848            flags: bytes[0],
2849            first_wall: read_u16(bytes, 8),
2850            wall_count: read_u16(bytes, 10),
2851        })
2852    }
2853
2854    /// Wall record by global wall index.
2855    pub fn wall(&self, index: u16) -> Option<WorldWall> {
2856        if index >= self.wall_count {
2857            return None;
2858        }
2859        let size = self.wall_record_size;
2860        let base = index as usize * size;
2861        let end = base.checked_add(size)?;
2862        let bytes = self.walls.get(base..end)?;
2863        Some(WorldWall::decode(bytes))
2864    }
2865
2866    /// Wall record by sector-local wall index.
2867    pub fn sector_wall(&self, sector: WorldSector, local_index: u16) -> Option<WorldWall> {
2868        if local_index >= sector.wall_count {
2869            return None;
2870        }
2871        self.wall(sector.first_wall.checked_add(local_index)?)
2872    }
2873
2874    /// Static surface-light record by direct table index.
2875    pub fn surface_light(&self, index: u16) -> Option<WorldSurfaceLight> {
2876        if !self.static_vertex_lighting || index >= self.surface_light_count {
2877            return None;
2878        }
2879        let size = psxed_format::world::SurfaceLightRecord::SIZE;
2880        let base = index as usize * size;
2881        let end = base.checked_add(size)?;
2882        let bytes = self.surface_lights.get(base..end)?;
2883        Some(read_world_surface_light(bytes, 0))
2884    }
2885
2886    fn horizontal_override(
2887        &self,
2888        sector_index: u16,
2889        surface: u8,
2890    ) -> Option<WorldHorizontalOverride> {
2891        let size = self.horizontal_override_record_size;
2892        let mut index = 0usize;
2893        while index < self.horizontal_override_count as usize {
2894            let base = index.checked_mul(size)?;
2895            let bytes = self.horizontal_overrides.get(base..base + size)?;
2896            if read_u16(bytes, 0) == sector_index && bytes[2] == surface {
2897                return Some(read_world_horizontal_override(bytes));
2898            }
2899            index += 1;
2900        }
2901        None
2902    }
2903
2904    fn horizontal_collision_override(
2905        &self,
2906        sector_index: u16,
2907        surface: u8,
2908        triangle: usize,
2909    ) -> Option<(u8, Option<[i32; 3]>)> {
2910        let size = self.horizontal_override_record_size;
2911        let mut index = 0usize;
2912        while index < self.horizontal_override_count as usize {
2913            let base = index.checked_mul(size)?;
2914            let bytes = self.horizontal_overrides.get(base..base + size)?;
2915            if read_u16(bytes, 0) == sector_index && bytes[2] == surface {
2916                let heights = (bytes.len() >= psxed_format::world::HorizontalOverrideRecord::SIZE)
2917                    .then(|| {
2918                        let offset = 24 + triangle.min(1) * 12;
2919                        [
2920                            read_i32(bytes, offset),
2921                            read_i32(bytes, offset + 4),
2922                            read_i32(bytes, offset + 8),
2923                        ]
2924                    });
2925                return Some((bytes[3], heights));
2926            }
2927            index += 1;
2928        }
2929        None
2930    }
2931}
2932
2933#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2934struct WorldHorizontalOverride {
2935    flags: u8,
2936    materials: [u16; 2],
2937    uvs: [WorldQuadUvs; 2],
2938    heights: Option<[[i32; 3]; 2]>,
2939}
2940
2941/// Minimal sector data for camera/wall collision probes.
2942#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2943pub struct WorldSectorCollisionProbe {
2944    flags: u8,
2945    first_wall: u16,
2946    wall_count: u16,
2947}
2948
2949/// Minimal decoded floor triangle used by character and camera collision.
2950#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2951pub struct WorldSectorFloorCollision {
2952    split: u8,
2953    triangle: u8,
2954    walkable: bool,
2955    floor_heights: [i32; 4],
2956    triangle_heights: [i32; 3],
2957}
2958
2959impl WorldSectorFloorCollision {
2960    /// Floor diagonal split id.
2961    #[inline]
2962    pub fn split(self) -> u8 {
2963        self.split
2964    }
2965
2966    /// Selected triangle index.
2967    #[inline]
2968    pub fn triangle(self) -> usize {
2969        self.triangle as usize
2970    }
2971
2972    /// Whether the selected floor triangle is walkable.
2973    #[inline]
2974    pub fn is_walkable(self) -> bool {
2975        self.walkable
2976    }
2977
2978    /// Renamed to [`Self::is_walkable`].
2979    #[deprecated(note = "renamed to `is_walkable`")]
2980    #[inline(always)]
2981    pub fn walkable(self) -> bool {
2982        self.is_walkable()
2983    }
2984
2985    /// Floor corner heights `[NW, NE, SE, SW]`.
2986    #[inline]
2987    pub fn floor_heights(self) -> [i32; 4] {
2988        self.floor_heights
2989    }
2990
2991    /// Selected triangle heights in triangle-corner order.
2992    #[inline]
2993    pub fn triangle_heights(self) -> [i32; 3] {
2994        self.triangle_heights
2995    }
2996}
2997
2998impl WorldSectorCollisionProbe {
2999    /// True if this sector contains any floor, ceiling, or wall data.
3000    #[inline]
3001    pub fn has_geometry(self) -> bool {
3002        self.has_floor() || self.has_ceiling() || self.wall_count != 0
3003    }
3004
3005    /// True if this sector has a floor surface.
3006    #[inline]
3007    pub fn has_floor(self) -> bool {
3008        self.flags & psxed_format::world::sector_flags::HAS_FLOOR != 0
3009    }
3010
3011    /// True if this sector has a ceiling surface.
3012    #[inline]
3013    pub fn has_ceiling(self) -> bool {
3014        self.flags & psxed_format::world::sector_flags::HAS_CEILING != 0
3015    }
3016
3017    /// First global wall index for this sector.
3018    #[inline]
3019    pub fn first_wall(self) -> u16 {
3020        self.first_wall
3021    }
3022
3023    /// Number of walls belonging to this sector.
3024    #[inline]
3025    pub fn wall_count(self) -> u16 {
3026        self.wall_count
3027    }
3028}
3029
3030/// One decoded world sector record.
3031#[derive(Copy, Clone, Debug, PartialEq, Eq)]
3032pub struct WorldSector {
3033    flags: u8,
3034    floor_split: u8,
3035    ceiling_split: u8,
3036    floor_material: u16,
3037    ceiling_material: u16,
3038    floor_triangle_flags: u8,
3039    ceiling_triangle_flags: u8,
3040    floor_triangle_materials: [u16; 2],
3041    ceiling_triangle_materials: [u16; 2],
3042    floor_triangle_heights: [[i32; 3]; 2],
3043    ceiling_triangle_heights: [[i32; 3]; 2],
3044    first_wall: u16,
3045    wall_count: u16,
3046    floor_heights: [i32; 4],
3047    ceiling_heights: [i32; 4],
3048    floor_uvs: WorldQuadUvs,
3049    ceiling_uvs: WorldQuadUvs,
3050    floor_triangle_uvs: [WorldQuadUvs; 2],
3051    ceiling_triangle_uvs: [WorldQuadUvs; 2],
3052}
3053
3054impl WorldSector {
3055    fn decode(
3056        bytes: &[u8],
3057        floor_override: Option<WorldHorizontalOverride>,
3058        ceiling_override: Option<WorldHorizontalOverride>,
3059    ) -> Self {
3060        let has_v2_uvs = bytes.len() >= WORLD_V2_SECTOR_RECORD_SIZE;
3061        let flags = bytes[0];
3062        let floor_material = read_u16(bytes, 4);
3063        let ceiling_material = read_u16(bytes, 6);
3064        let floor_heights = read_i32x4(bytes, 12);
3065        let ceiling_heights = read_i32x4(bytes, 28);
3066        let floor_uvs = if has_v2_uvs {
3067            read_world_uvs(bytes, 44)
3068        } else {
3069            WorldQuadUvs::new(psxed_format::world::FLOOR_UVS)
3070        };
3071        let ceiling_uvs = if has_v2_uvs {
3072            read_world_uvs(bytes, 52)
3073        } else {
3074            WorldQuadUvs::new(psxed_format::world::FLOOR_UVS)
3075        };
3076        let floor_default_flags = default_horizontal_flags(
3077            flags & psxed_format::world::sector_flags::HAS_FLOOR != 0,
3078            flags & psxed_format::world::sector_flags::FLOOR_WALKABLE != 0,
3079        );
3080        let ceiling_default_flags = default_horizontal_flags(
3081            flags & psxed_format::world::sector_flags::HAS_CEILING != 0,
3082            flags & psxed_format::world::sector_flags::CEILING_WALKABLE != 0,
3083        );
3084        let floor_triangle_flags =
3085            floor_override.map_or(floor_default_flags, |override_data| override_data.flags);
3086        let ceiling_triangle_flags =
3087            ceiling_override.map_or(ceiling_default_flags, |override_data| override_data.flags);
3088        let floor_triangle_materials = floor_override
3089            .map_or([floor_material, floor_material], |override_data| {
3090                override_data.materials
3091            });
3092        let ceiling_triangle_materials = ceiling_override
3093            .map_or([ceiling_material, ceiling_material], |override_data| {
3094                override_data.materials
3095            });
3096        let floor_triangle_uvs =
3097            floor_override.map_or([floor_uvs, floor_uvs], |override_data| override_data.uvs);
3098        let ceiling_triangle_uvs = ceiling_override
3099            .map_or([ceiling_uvs, ceiling_uvs], |override_data| {
3100                override_data.uvs
3101            });
3102        let floor_triangle_heights = floor_override
3103            .and_then(|override_data| override_data.heights)
3104            .unwrap_or_else(|| {
3105                [
3106                    horizontal_triangle_heights(floor_heights, bytes[1], 0),
3107                    horizontal_triangle_heights(floor_heights, bytes[1], 1),
3108                ]
3109            });
3110        let ceiling_triangle_heights = ceiling_override
3111            .and_then(|override_data| override_data.heights)
3112            .unwrap_or_else(|| {
3113                [
3114                    horizontal_triangle_heights(ceiling_heights, bytes[2], 0),
3115                    horizontal_triangle_heights(ceiling_heights, bytes[2], 1),
3116                ]
3117            });
3118        Self {
3119            flags,
3120            floor_split: bytes[1],
3121            ceiling_split: bytes[2],
3122            floor_material,
3123            ceiling_material,
3124            floor_triangle_flags,
3125            ceiling_triangle_flags,
3126            floor_triangle_materials,
3127            ceiling_triangle_materials,
3128            floor_triangle_heights,
3129            ceiling_triangle_heights,
3130            first_wall: read_u16(bytes, 8),
3131            wall_count: read_u16(bytes, 10),
3132            floor_heights,
3133            ceiling_heights,
3134            floor_uvs,
3135            ceiling_uvs,
3136            floor_triangle_uvs,
3137            ceiling_triangle_uvs,
3138        }
3139    }
3140
3141    /// True if this sector has any floor, ceiling, or wall geometry.
3142    #[inline]
3143    pub fn has_geometry(&self) -> bool {
3144        self.has_floor() || self.has_ceiling() || self.wall_count != 0
3145    }
3146
3147    /// True if this sector has a floor face.
3148    #[inline]
3149    pub fn has_floor(&self) -> bool {
3150        self.flags & psxed_format::world::sector_flags::HAS_FLOOR != 0
3151            && (self.has_floor_triangle(0) || self.has_floor_triangle(1))
3152    }
3153
3154    /// True if this sector has a ceiling face.
3155    #[inline]
3156    pub fn has_ceiling(&self) -> bool {
3157        self.flags & psxed_format::world::sector_flags::HAS_CEILING != 0
3158            && (self.has_ceiling_triangle(0) || self.has_ceiling_triangle(1))
3159    }
3160
3161    /// True if the floor face is walkable.
3162    #[inline]
3163    pub fn is_floor_walkable(&self) -> bool {
3164        self.is_floor_triangle_walkable(0) || self.is_floor_triangle_walkable(1)
3165    }
3166
3167    /// Renamed to [`Self::is_floor_walkable`].
3168    #[deprecated(note = "renamed to `is_floor_walkable`")]
3169    #[inline(always)]
3170    pub fn floor_walkable(&self) -> bool {
3171        self.is_floor_walkable()
3172    }
3173
3174    /// Floor diagonal split id.
3175    #[inline]
3176    pub fn floor_split(&self) -> u8 {
3177        self.floor_split
3178    }
3179
3180    /// Floor material slot.
3181    #[inline]
3182    pub fn floor_material(&self) -> Option<u16> {
3183        self.floor_triangle_material(0)
3184            .or_else(|| self.floor_triangle_material(1))
3185            .or_else(|| material_or_none(self.floor_material))
3186    }
3187
3188    /// Floor corner heights `[NW, NE, SE, SW]`.
3189    #[inline]
3190    pub fn floor_heights(&self) -> [i32; 4] {
3191        self.floor_heights
3192    }
3193
3194    /// Floor UVs `[NW, NE, SE, SW]`.
3195    #[inline]
3196    pub fn floor_uvs(&self) -> WorldQuadUvs {
3197        self.floor_uvs
3198    }
3199
3200    /// `true` if a floor split triangle is present.
3201    #[inline]
3202    pub fn has_floor_triangle(&self, index: usize) -> bool {
3203        horizontal_triangle_present(self.floor_triangle_flags, index)
3204    }
3205
3206    /// Renamed to [`Self::has_floor_triangle`].
3207    #[deprecated(note = "renamed to `has_floor_triangle`")]
3208    #[inline(always)]
3209    pub fn floor_triangle_present(&self, index: usize) -> bool {
3210        self.has_floor_triangle(index)
3211    }
3212
3213    /// Floor split-triangle material slot.
3214    #[inline]
3215    pub fn floor_triangle_material(&self, index: usize) -> Option<u16> {
3216        if self.has_floor_triangle(index) {
3217            material_or_none(self.floor_triangle_materials[index.min(1)])
3218        } else {
3219            None
3220        }
3221    }
3222
3223    /// Floor split-triangle UVs in face-corner order.
3224    #[inline]
3225    pub fn floor_triangle_uvs(&self, index: usize) -> WorldQuadUvs {
3226        self.floor_triangle_uvs[index.min(1)]
3227    }
3228
3229    /// Floor split-triangle heights in that triangle's corner order.
3230    #[inline]
3231    pub fn floor_triangle_heights(&self, index: usize) -> [i32; 3] {
3232        self.floor_triangle_heights[index.min(1)]
3233    }
3234
3235    /// `true` if a floor split triangle is walkable.
3236    #[inline]
3237    pub fn is_floor_triangle_walkable(&self, index: usize) -> bool {
3238        self.has_floor_triangle(index)
3239            && horizontal_triangle_walkable(self.floor_triangle_flags, index)
3240    }
3241
3242    /// Renamed to [`Self::is_floor_triangle_walkable`].
3243    #[deprecated(note = "renamed to `is_floor_triangle_walkable`")]
3244    #[inline(always)]
3245    pub fn floor_triangle_walkable(&self, index: usize) -> bool {
3246        self.is_floor_triangle_walkable(index)
3247    }
3248
3249    /// Ceiling diagonal split id.
3250    #[inline]
3251    pub fn ceiling_split(&self) -> u8 {
3252        self.ceiling_split
3253    }
3254
3255    /// Ceiling material slot.
3256    #[inline]
3257    pub fn ceiling_material(&self) -> Option<u16> {
3258        self.ceiling_triangle_material(0)
3259            .or_else(|| self.ceiling_triangle_material(1))
3260            .or_else(|| material_or_none(self.ceiling_material))
3261    }
3262
3263    /// Ceiling corner heights `[NW, NE, SE, SW]`.
3264    #[inline]
3265    pub fn ceiling_heights(&self) -> [i32; 4] {
3266        self.ceiling_heights
3267    }
3268
3269    /// Ceiling UVs `[NW, NE, SE, SW]`.
3270    #[inline]
3271    pub fn ceiling_uvs(&self) -> WorldQuadUvs {
3272        self.ceiling_uvs
3273    }
3274
3275    /// `true` if a ceiling split triangle is present.
3276    #[inline]
3277    pub fn has_ceiling_triangle(&self, index: usize) -> bool {
3278        horizontal_triangle_present(self.ceiling_triangle_flags, index)
3279    }
3280
3281    /// Renamed to [`Self::has_ceiling_triangle`].
3282    #[deprecated(note = "renamed to `has_ceiling_triangle`")]
3283    #[inline(always)]
3284    pub fn ceiling_triangle_present(&self, index: usize) -> bool {
3285        self.has_ceiling_triangle(index)
3286    }
3287
3288    /// Ceiling split-triangle material slot.
3289    #[inline]
3290    pub fn ceiling_triangle_material(&self, index: usize) -> Option<u16> {
3291        if self.has_ceiling_triangle(index) {
3292            material_or_none(self.ceiling_triangle_materials[index.min(1)])
3293        } else {
3294            None
3295        }
3296    }
3297
3298    /// Ceiling split-triangle UVs in face-corner order.
3299    #[inline]
3300    pub fn ceiling_triangle_uvs(&self, index: usize) -> WorldQuadUvs {
3301        self.ceiling_triangle_uvs[index.min(1)]
3302    }
3303
3304    /// Ceiling split-triangle heights in that triangle's corner order.
3305    #[inline]
3306    pub fn ceiling_triangle_heights(&self, index: usize) -> [i32; 3] {
3307        self.ceiling_triangle_heights[index.min(1)]
3308    }
3309
3310    /// `true` if a ceiling split triangle is walkable.
3311    #[inline]
3312    pub fn is_ceiling_triangle_walkable(&self, index: usize) -> bool {
3313        self.has_ceiling_triangle(index)
3314            && horizontal_triangle_walkable(self.ceiling_triangle_flags, index)
3315    }
3316
3317    /// First global wall index for this sector.
3318    #[inline]
3319    pub fn first_wall(&self) -> u16 {
3320        self.first_wall
3321    }
3322
3323    /// Number of walls belonging to this sector.
3324    #[inline]
3325    pub fn wall_count(&self) -> u16 {
3326        self.wall_count
3327    }
3328}
3329
3330/// One decoded world wall record.
3331#[derive(Copy, Clone, Debug, PartialEq, Eq)]
3332pub struct WorldWall {
3333    direction: u8,
3334    flags: u8,
3335    material: u16,
3336    shape: u16,
3337    heights: [i32; 4],
3338    uvs: WorldQuadUvs,
3339}
3340
3341impl WorldWall {
3342    fn decode(bytes: &[u8]) -> Self {
3343        let has_v2_uvs = bytes.len() >= WORLD_V2_WALL_RECORD_SIZE;
3344        Self {
3345            direction: bytes[0],
3346            flags: bytes[1],
3347            material: read_u16(bytes, 4),
3348            shape: if has_v2_uvs {
3349                read_u16(bytes, 6)
3350            } else {
3351                psxed_format::world::wall_shape::QUAD
3352            },
3353            heights: read_i32x4(bytes, 8),
3354            uvs: if has_v2_uvs {
3355                read_world_uvs(bytes, 24)
3356            } else {
3357                WorldQuadUvs::new(psxed_format::world::WALL_UVS)
3358            },
3359        }
3360    }
3361
3362    /// Direction id, see `psxed_format::world::direction`.
3363    #[inline]
3364    pub fn direction(&self) -> u8 {
3365        self.direction
3366    }
3367
3368    /// Whether this wall blocks collision.
3369    #[inline]
3370    pub fn is_solid(&self) -> bool {
3371        self.flags & psxed_format::world::wall_flags::SOLID != 0
3372    }
3373
3374    /// Material slot.
3375    #[inline]
3376    pub fn material(&self) -> u16 {
3377        self.material
3378    }
3379
3380    /// Wall shape id, see `psxed_format::world::wall_shape`.
3381    #[inline]
3382    pub fn shape(&self) -> u16 {
3383        self.shape
3384    }
3385
3386    /// Wall heights `[bottom-left, bottom-right, top-right, top-left]`.
3387    #[inline]
3388    pub fn heights(&self) -> [i32; 4] {
3389        self.heights
3390    }
3391
3392    /// Wall UVs `[bottom-left, bottom-right, top-right, top-left]`.
3393    #[inline]
3394    pub fn uvs(&self) -> WorldQuadUvs {
3395        self.uvs
3396    }
3397}
3398
3399#[inline]
3400fn material_or_none(material: u16) -> Option<u16> {
3401    if material == psxed_format::world::NO_MATERIAL {
3402        None
3403    } else {
3404        Some(material)
3405    }
3406}
3407
3408const fn default_horizontal_flags(present: bool, walkable: bool) -> u8 {
3409    if !present {
3410        0
3411    } else {
3412        let mut flags = psxed_format::world::horizontal_flags::TRI_A_PRESENT
3413            | psxed_format::world::horizontal_flags::TRI_B_PRESENT;
3414        if walkable {
3415            flags |= psxed_format::world::horizontal_flags::TRI_A_WALKABLE
3416                | psxed_format::world::horizontal_flags::TRI_B_WALKABLE;
3417        }
3418        flags
3419    }
3420}
3421
3422const fn horizontal_triangle_present(flags: u8, index: usize) -> bool {
3423    let bit = if index == 0 {
3424        psxed_format::world::horizontal_flags::TRI_A_PRESENT
3425    } else {
3426        psxed_format::world::horizontal_flags::TRI_B_PRESENT
3427    };
3428    flags & bit != 0
3429}
3430
3431const fn horizontal_triangle_walkable(flags: u8, index: usize) -> bool {
3432    let bit = if index == 0 {
3433        psxed_format::world::horizontal_flags::TRI_A_WALKABLE
3434    } else {
3435        psxed_format::world::horizontal_flags::TRI_B_WALKABLE
3436    };
3437    flags & bit != 0
3438}
3439
3440fn horizontal_triangle_heights(heights: [i32; 4], split: u8, index: usize) -> [i32; 3] {
3441    let corners = psxed_format::world::topology::split_triangle(split, index);
3442    [
3443        heights[corners[0]],
3444        heights[corners[1]],
3445        heights[corners[2]],
3446    ]
3447}
3448
3449fn checked_table_bytes(count: u16, stride: usize) -> Result<usize, ParseError> {
3450    (count as usize)
3451        .checked_mul(stride)
3452        .ok_or(ParseError::TableOverflow)
3453}
3454
3455fn take_table<'a>(bytes: &'a [u8], offset: &mut usize, len: usize) -> Result<&'a [u8], ParseError> {
3456    let end = offset.checked_add(len).ok_or(ParseError::TableOverflow)?;
3457    if end > bytes.len() {
3458        return Err(ParseError::TableOverflow);
3459    }
3460    let table = &bytes[*offset..end];
3461    *offset = end;
3462    Ok(table)
3463}
3464
3465fn validate_model_parts(
3466    parts: &[u8],
3467    joint_count: u16,
3468    material_count: u16,
3469    vertex_count: u16,
3470    face_count: u16,
3471) -> Result<(), ParseError> {
3472    let size = psxed_format::model::PartRecord::SIZE;
3473    let count = parts.len() / size;
3474    for index in 0..count {
3475        let base = index * size;
3476        let joint_index = read_u16(parts, base);
3477        let first_vertex = read_u16(parts, base + 2);
3478        let part_vertex_count = read_u16(parts, base + 4);
3479        let first_face = read_u16(parts, base + 6);
3480        let part_face_count = read_u16(parts, base + 8);
3481        let material_index = read_u16(parts, base + 10);
3482
3483        if joint_index != psxed_format::model::NO_JOINT && joint_index >= joint_count {
3484            return Err(ParseError::InvalidModelLayout);
3485        }
3486        if material_index >= material_count {
3487            return Err(ParseError::InvalidModelLayout);
3488        }
3489        let Some(vertex_end) = first_vertex.checked_add(part_vertex_count) else {
3490            return Err(ParseError::InvalidModelLayout);
3491        };
3492        if vertex_end > vertex_count {
3493            return Err(ParseError::InvalidModelLayout);
3494        }
3495        let Some(face_end) = first_face.checked_add(part_face_count) else {
3496            return Err(ParseError::InvalidModelLayout);
3497        };
3498        if face_end > face_count {
3499            return Err(ParseError::InvalidModelLayout);
3500        }
3501    }
3502    Ok(())
3503}
3504
3505fn validate_model_faces(faces: &[u8], vertex_count: u16) -> Result<(), ParseError> {
3506    let size = psxed_format::model::FACE_RECORD_SIZE;
3507    let count = faces.len() / size;
3508    for index in 0..count {
3509        let base = index * size;
3510        let a = read_u16(faces, base);
3511        let b = read_u16(faces, base + 4);
3512        let c = read_u16(faces, base + 8);
3513        if a >= vertex_count || b >= vertex_count || c >= vertex_count {
3514            return Err(ParseError::InvalidModelLayout);
3515        }
3516    }
3517    Ok(())
3518}
3519
3520#[inline]
3521fn read_u16(bytes: &[u8], offset: usize) -> u16 {
3522    u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
3523}
3524
3525#[inline]
3526fn read_u32(bytes: &[u8], offset: usize) -> u32 {
3527    u32::from_le_bytes([
3528        bytes[offset],
3529        bytes[offset + 1],
3530        bytes[offset + 2],
3531        bytes[offset + 3],
3532    ])
3533}
3534
3535#[inline]
3536fn read_i16(bytes: &[u8], offset: usize) -> i16 {
3537    i16::from_le_bytes([bytes[offset], bytes[offset + 1]])
3538}
3539
3540/// Little-endian `i16` at `offset`, without a bounds check.
3541///
3542/// # Safety
3543/// `offset + 2` must be `<= bytes.len()`.
3544#[inline]
3545unsafe fn read_i16_unchecked(bytes: &[u8], offset: usize) -> i16 {
3546    // SAFETY: `offset` and `offset + 1` are both `< offset + 2 <= len`, as
3547    // the caller guarantees.
3548    let lo = unsafe { *bytes.get_unchecked(offset) };
3549    // SAFETY: as above.
3550    let hi = unsafe { *bytes.get_unchecked(offset + 1) };
3551    i16::from_le_bytes([lo, hi])
3552}
3553
3554/// Little-endian `i16` at `offset` via one halfword load.
3555///
3556/// # Safety
3557/// `offset + 2` must be `<= bytes.len()` and `bytes.as_ptr().add(offset)`
3558/// must be 2-byte aligned.
3559#[inline]
3560unsafe fn read_i16_aligned_unchecked(bytes: &[u8], offset: usize) -> i16 {
3561    // SAFETY: `offset < offset + 2 <= len` per the caller, so the pointer
3562    // stays inside the slice.
3563    debug_assert_eq!((unsafe { bytes.as_ptr().add(offset) } as usize) & 1, 0);
3564    // SAFETY: the caller guarantees two readable bytes at `offset` and 2-byte
3565    // alignment, which is everything an `i16` read needs.
3566    i16::from_le(unsafe { bytes.as_ptr().add(offset).cast::<i16>().read() })
3567}
3568
3569#[inline]
3570fn read_i32(bytes: &[u8], offset: usize) -> i32 {
3571    i32::from_le_bytes([
3572        bytes[offset],
3573        bytes[offset + 1],
3574        bytes[offset + 2],
3575        bytes[offset + 3],
3576    ])
3577}
3578
3579/// Little-endian `i32` at `offset`, read byte-wise without a bounds check.
3580///
3581/// # Safety
3582/// `offset + 4` must be `<= bytes.len()`.
3583#[inline]
3584unsafe fn read_i32_unchecked(bytes: &[u8], offset: usize) -> i32 {
3585    i32::from_le_bytes([
3586        // SAFETY: each index below is `< offset + 4 <= len` per the caller.
3587        unsafe { *bytes.get_unchecked(offset) },
3588        // SAFETY: as above.
3589        unsafe { *bytes.get_unchecked(offset + 1) },
3590        // SAFETY: as above.
3591        unsafe { *bytes.get_unchecked(offset + 2) },
3592        // SAFETY: as above.
3593        unsafe { *bytes.get_unchecked(offset + 3) },
3594    ])
3595}
3596
3597#[inline]
3598fn read_i32x4(bytes: &[u8], offset: usize) -> [i32; 4] {
3599    [
3600        read_i32(bytes, offset),
3601        read_i32(bytes, offset + 4),
3602        read_i32(bytes, offset + 8),
3603        read_i32(bytes, offset + 12),
3604    ]
3605}
3606
3607#[inline]
3608fn read_world_uvs(bytes: &[u8], offset: usize) -> WorldQuadUvs {
3609    WorldQuadUvs::new([
3610        (bytes[offset], bytes[offset + 1]),
3611        (bytes[offset + 2], bytes[offset + 3]),
3612        (bytes[offset + 4], bytes[offset + 5]),
3613        (bytes[offset + 6], bytes[offset + 7]),
3614    ])
3615}
3616
3617#[inline]
3618fn read_world_horizontal_override(bytes: &[u8]) -> WorldHorizontalOverride {
3619    WorldHorizontalOverride {
3620        flags: bytes[3],
3621        materials: [read_u16(bytes, 4), read_u16(bytes, 6)],
3622        uvs: [read_world_uvs(bytes, 8), read_world_uvs(bytes, 16)],
3623        heights: (bytes.len() >= psxed_format::world::HorizontalOverrideRecord::SIZE).then(|| {
3624            [
3625                [
3626                    read_i32(bytes, 24),
3627                    read_i32(bytes, 28),
3628                    read_i32(bytes, 32),
3629                ],
3630                [
3631                    read_i32(bytes, 36),
3632                    read_i32(bytes, 40),
3633                    read_i32(bytes, 44),
3634                ],
3635            ]
3636        }),
3637    }
3638}
3639
3640#[inline]
3641fn read_world_surface_light(bytes: &[u8], offset: usize) -> WorldSurfaceLight {
3642    WorldSurfaceLight::new([
3643        [bytes[offset], bytes[offset + 1], bytes[offset + 2]],
3644        [bytes[offset + 3], bytes[offset + 4], bytes[offset + 5]],
3645        [bytes[offset + 6], bytes[offset + 7], bytes[offset + 8]],
3646        [bytes[offset + 9], bytes[offset + 10], bytes[offset + 11]],
3647    ])
3648}
3649
3650fn validate_horizontal_overrides(
3651    overrides: &[u8],
3652    override_count: u16,
3653    sector_count: u16,
3654    record_size: usize,
3655) -> Result<(), ParseError> {
3656    let size = record_size;
3657    if overrides.len() != override_count as usize * size {
3658        return Err(ParseError::InvalidWorldLayout);
3659    }
3660    for index in 0..override_count as usize {
3661        let base = index * size;
3662        let sector_index = read_u16(overrides, base);
3663        let surface = overrides[base + 2];
3664        if sector_index >= sector_count {
3665            return Err(ParseError::InvalidWorldLayout);
3666        }
3667        if surface != psxed_format::world::horizontal_surface::FLOOR
3668            && surface != psxed_format::world::horizontal_surface::CEILING
3669        {
3670            return Err(ParseError::InvalidWorldLayout);
3671        }
3672    }
3673    Ok(())
3674}
3675
3676fn validate_sector_wall_ranges(
3677    sectors: &[u8],
3678    sector_record_size: usize,
3679    wall_count: u16,
3680) -> Result<(), ParseError> {
3681    let size = sector_record_size;
3682    let count = sectors.len() / size;
3683    for index in 0..count {
3684        let base = index * size;
3685        let first_wall = read_u16(sectors, base + 8);
3686        let sector_wall_count = read_u16(sectors, base + 10);
3687        let Some(end) = first_wall.checked_add(sector_wall_count) else {
3688            return Err(ParseError::InvalidWorldLayout);
3689        };
3690        if end > wall_count {
3691            return Err(ParseError::InvalidWorldLayout);
3692        }
3693    }
3694    Ok(())
3695}
3696
3697#[cfg(test)]
3698extern crate std;
3699
3700#[cfg(test)]
3701mod tests;