Decode tfrag texture bindings, vertex colours and light records
Adds level_core.rs (the uncompressed core index header and texture table) and extends tfrag.rs with the data a draw call needs beyond geometry. An ad-gif is five A+D quadwords holding packed fields rather than GS registers; the level load rewrites them in place before drawing. On disc TEX0_1.data_lo indexes the level's texture table, TEX1_1.data_lo is the LOD bias K and data_hi the minification filter, and CLAMP_1 holds both wrap modes. Three things are easy to get wrong here: * K is a signed 12-bit value in 1/16 units, sign-extended by shifting the field up 4 and back down. Retail values are -137..-89 mip-scaled; reading it unsigned gives 1911 instead of -137. * TEX1_1.data_hi is MMIN, and 4 decodes as LinearMipmapNearest, not LinearMipmapLinear as the raw bit layout suggests. * RGBA and the light/normal records are not in the VIF lists at all. They sit at fixed offsets in the patch data block: rgba_size*4 colours at rgba_ofs, vert_count light records at light_ofs+0x10. The tfrag origin lives there too -- if no STROW row supplied one, the quadword at light_ofs is it. The texture table checks out on its own terms: 93 entries at index offset 0x2900 on level 0, and 0x2900 + 93*16 lands exactly on the next range. Verification: an ad-gif index outside the level's table would mean the geometry and the textures were misread independently, so every one is range-checked against real entries. All 27 levels: 47,950 ad-gifs over 18,102 patches, all in range, referencing 1429 of 1595 level texture entries (90%). Each patch's light array is exactly vert_count records and its RGBA array exactly rgba_size*4. Still open: LOD selection and morphing, and the texel data itself.
This commit is contained in:
@@ -564,7 +564,7 @@ fn tfrag_topology_reproduces_the_header_triangle_count() {
|
||||
idx.iter().all(|&v| v < vinfo),
|
||||
"level{n} patch {i}: triangle index outside {vinfo} vertex-info entries"
|
||||
);
|
||||
assert!((t.ad_gif as usize) < g.ad_gif_count);
|
||||
assert!((t.ad_gif as usize) < g.ad_gifs.len());
|
||||
}
|
||||
patches += 1;
|
||||
triangles += tris.len();
|
||||
@@ -580,3 +580,127 @@ fn tfrag_topology_reproduces_the_header_triangle_count() {
|
||||
);
|
||||
eprintln!("reproduced {triangles} triangles across {patches} patches in {levels} levels");
|
||||
}
|
||||
|
||||
/// Texture bindings: every ad-gif a patch uses must index an entry that
|
||||
/// actually exists in the level's texture table, and the light array must be
|
||||
/// one record per vertex.
|
||||
///
|
||||
/// The texture table is the join between tfrags and the texel data, so an
|
||||
/// out-of-range index here means the level's geometry and its textures have
|
||||
/// been misread independently of each other.
|
||||
#[test]
|
||||
fn tfrag_ad_gifs_index_the_level_texture_table() {
|
||||
require_disc!();
|
||||
gc_formats::iso9660::IsoImage::open(std::path::Path::new(
|
||||
&std::env::var("GC_DISC").expect("GC_DISC is set by require_disc!"),
|
||||
))
|
||||
.expect("ISO should open");
|
||||
let data = std::env::var_os("GC_EXTRACTED")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home).join("Documents/gc-extracted")
|
||||
});
|
||||
if !data.exists() {
|
||||
eprintln!("skipped: unpack the disc first");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut patches = 0usize;
|
||||
let mut ad_gifs = 0usize;
|
||||
let mut textures_seen = 0usize;
|
||||
let mut textures_total = 0usize;
|
||||
let mut levels = 0usize;
|
||||
|
||||
for n in 0..27 {
|
||||
let dir = data.join(format!("level{n}"));
|
||||
let core = match std::fs::read(dir.join("core_data.bin")) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let index = match std::fs::read(dir.join("core_index.bin")) {
|
||||
Ok(i) => i,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let ch = gc_formats::level_core::LevelCoreHeader::parse(&index).expect("core header");
|
||||
let textures = gc_formats::level_core::texture_table(&index, ch.tfrag_textures)
|
||||
.expect("texture table");
|
||||
assert!(!textures.is_empty(), "level{n}: no tfrag textures");
|
||||
// Indices are per-level, so the pair is what identifies a texture.
|
||||
let mut level_seen = std::collections::BTreeSet::new();
|
||||
|
||||
let region =
|
||||
|off: usize| i32::from_le_bytes(index[off..off + 4].try_into().unwrap()) as usize;
|
||||
let start = region(0x08);
|
||||
let end = [0x0c_usize, 0x10, 0x14]
|
||||
.iter()
|
||||
.map(|&o| region(o))
|
||||
.filter(|&v| v > 0)
|
||||
.min()
|
||||
.unwrap_or(core.len());
|
||||
let block = &core[start..end];
|
||||
|
||||
let header = gc_formats::tfrag::TfragsBlock::parse(block).expect("tfrags block header");
|
||||
let table = header.table_offset as usize;
|
||||
for i in 0..header.tfrag_count as usize {
|
||||
let th = header.header(block, i).expect("tfrag header");
|
||||
let data_base = table.wrapping_add(th.data as usize);
|
||||
if data_base >= block.len() {
|
||||
continue;
|
||||
}
|
||||
let g = gc_formats::tfrag::parse_tfrag(&block[data_base..], &th)
|
||||
.unwrap_or_else(|e| panic!("level{n} patch {i}: {e}"));
|
||||
|
||||
assert_eq!(
|
||||
g.lights.len(),
|
||||
th.vert_count as usize,
|
||||
"level{n} patch {i}: {} light records for {} vertices",
|
||||
g.lights.len(),
|
||||
th.vert_count
|
||||
);
|
||||
assert_eq!(
|
||||
g.rgba.len(),
|
||||
th.rgba_size as usize * 4,
|
||||
"level{n} patch {i}: rgba"
|
||||
);
|
||||
|
||||
for a in &g.ad_gifs {
|
||||
let t = a.texture_index();
|
||||
assert!(
|
||||
t < textures.len(),
|
||||
"level{n} patch {i}: texture {} outside the {} entry table",
|
||||
t,
|
||||
textures.len()
|
||||
);
|
||||
let tex = &textures[t];
|
||||
assert!(
|
||||
tex.width > 0 && tex.height > 0 && tex.width <= 256 && tex.height <= 256,
|
||||
"level{n} patch {i}: texture {t} is {}x{}",
|
||||
tex.width,
|
||||
tex.height
|
||||
);
|
||||
level_seen.insert(t);
|
||||
ad_gifs += 1;
|
||||
}
|
||||
patches += 1;
|
||||
}
|
||||
textures_seen += level_seen.len();
|
||||
textures_total += textures.len();
|
||||
levels += 1;
|
||||
}
|
||||
|
||||
assert!(levels > 0, "no unpacked levels were found");
|
||||
assert!(patches > 18_000, "expected 18k+ patches, got {patches}");
|
||||
// Most of each level's texture table should be referenced by some tfrag. A
|
||||
// handful genuinely go unused; if that share grows, the ad-gif walk has
|
||||
// started missing primitives rather than the data having spare entries.
|
||||
let covered = textures_seen as f64 / textures_total as f64;
|
||||
assert!(
|
||||
covered > 0.85,
|
||||
"only {textures_seen} of {textures_total} level textures referenced ({:.1}%)",
|
||||
covered * 100.0
|
||||
);
|
||||
eprintln!(
|
||||
"{ad_gifs} ad-gifs over {patches} patches reference {textures_seen}/{textures_total} level textures"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! The level core **index**: the uncompressed directory that sits alongside
|
||||
//! `core_data` and points into it.
|
||||
//!
|
||||
//! Spec: `docs/formats/level-core.md`.
|
||||
//!
|
||||
//! The index is small (about 31 KB) and mostly a set of count/offset pairs. Two
|
||||
//! things live here that the compressed data cannot supply: where each block of
|
||||
//! `core_data` starts, and the per-level tables the geometry refers to by
|
||||
//! index — most importantly the texture table that tfrag ad-gifs point into.
|
||||
//!
|
||||
//! Only the fields this crate actually reads are decoded. The class tables at
|
||||
//! 0x18..0x30 resisted every field order and stride tried and are deliberately
|
||||
//! left alone; see `docs/plan/level-assets.md`.
|
||||
|
||||
use crate::buf::{Buf, Error, ErrorKind, Result};
|
||||
|
||||
/// A count/offset pair naming an array in the index.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ArrayRange {
|
||||
pub count: i32,
|
||||
pub offset: i32,
|
||||
}
|
||||
|
||||
/// One texture in the level's texture table (16 bytes).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TextureEntry {
|
||||
/// Byte offset of the texel data within the level's texture base.
|
||||
pub data_offset: i32,
|
||||
pub width: i16,
|
||||
pub height: i16,
|
||||
/// Mip level count (3 or 4 on retail data).
|
||||
pub ty: i16,
|
||||
/// CLUT index within the level's palette space.
|
||||
pub palette: i16,
|
||||
/// Byte offset of mip level 1.
|
||||
pub mipmap: i16,
|
||||
/// Byte offset of the second mip chain.
|
||||
pub pad: i16,
|
||||
}
|
||||
|
||||
impl TextureEntry {
|
||||
pub fn parse(bytes: &[u8]) -> Result<TextureEntry> {
|
||||
let mut b = Buf::new(bytes);
|
||||
Ok(TextureEntry {
|
||||
data_offset: b.i32("texture data offset")?,
|
||||
width: b.i16("texture width")?,
|
||||
height: b.i16("texture height")?,
|
||||
ty: b.i16("texture ty")?,
|
||||
palette: b.i16("texture palette")?,
|
||||
mipmap: b.i16("texture mipmap")?,
|
||||
pad: b.i16("texture pad")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ceil(log2(n))`, the GS TBW/TSB derivation.
|
||||
///
|
||||
/// PS2 texture widths are `width << 6` texels wide, so a 128 px texture is
|
||||
/// 8192 texels and needs 13.
|
||||
pub fn log2_size(&self) -> i32 {
|
||||
let n = (self.width as i32) << 6;
|
||||
let mut bits = 0;
|
||||
while (1 << bits) < n.max(1) {
|
||||
bits += 1;
|
||||
}
|
||||
bits
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a texture table.
|
||||
pub fn texture_table(index: &[u8], range: ArrayRange) -> Result<Vec<TextureEntry>> {
|
||||
let count = range.count.max(0) as usize;
|
||||
let offset = range.offset.max(0) as usize;
|
||||
// Bound the whole table before slicing, so an absurd count reports an error
|
||||
// instead of panicking partway through.
|
||||
let need = count
|
||||
.checked_mul(16)
|
||||
.and_then(|n| offset.checked_add(n))
|
||||
.ok_or(Error {
|
||||
offset,
|
||||
context: "texture table range overflows",
|
||||
kind: ErrorKind::InvalidLength("texture table"),
|
||||
})?;
|
||||
if need > index.len() {
|
||||
return Err(Error {
|
||||
offset,
|
||||
context: "texture table runs past the index",
|
||||
kind: ErrorKind::OutOfBounds {
|
||||
needed: need,
|
||||
available: index.len(),
|
||||
},
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let at = offset + i * 16;
|
||||
out.push(TextureEntry::parse(&index[at..at + 16])?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The level core index header.
|
||||
///
|
||||
/// Only fields this crate reads are decoded. Offsets are byte offsets into the
|
||||
/// uncompressed index.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LevelCoreHeader {
|
||||
/// GS packet memory in use.
|
||||
pub gs_ram: ArrayRange,
|
||||
/// Byte offset of the tfrags block within `core_data`.
|
||||
pub tfrags: i32,
|
||||
/// Byte offset of the occlusion block.
|
||||
pub occlusion: i32,
|
||||
/// Byte offset of the sky block.
|
||||
pub sky: i32,
|
||||
/// Byte offset of the collision block.
|
||||
pub collision: i32,
|
||||
/// The table tfrag ad-gifs index into.
|
||||
pub tfrag_textures: ArrayRange,
|
||||
/// Byte offset of texture pixel data within `core_data`.
|
||||
pub textures_base: i32,
|
||||
}
|
||||
|
||||
impl LevelCoreHeader {
|
||||
pub fn parse(bytes: &[u8]) -> Result<LevelCoreHeader> {
|
||||
let mut b = Buf::new(bytes);
|
||||
let pair = |b: &mut Buf, what: &'static str| -> Result<ArrayRange> {
|
||||
let count = b.i32(what)?;
|
||||
let offset = b.i32(what)?;
|
||||
Ok(ArrayRange { count, offset })
|
||||
};
|
||||
let gs_ram = pair(&mut b, "gs_ram")?;
|
||||
let tfrags = b.i32("tfrags")?;
|
||||
let occlusion = b.i32("occlusion")?;
|
||||
let sky = b.i32("sky")?;
|
||||
let collision = b.i32("collision")?;
|
||||
// 0x18..0x30: moby/tie/shrub/gadget class tables, not decoded yet.
|
||||
b.skip(0x30 - 0x18, "class tables")?;
|
||||
let tfrag_textures = pair(&mut b, "tfrag textures")?;
|
||||
b.skip(0x60 - 0x38, "texture tables")?;
|
||||
let textures_base = b.i32("textures base")?;
|
||||
Ok(LevelCoreHeader {
|
||||
gs_ram,
|
||||
tfrags,
|
||||
occlusion,
|
||||
sky,
|
||||
collision,
|
||||
tfrag_textures,
|
||||
textures_base,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A header as measured on level 0.
|
||||
fn header() -> Vec<u8> {
|
||||
let mut v = vec![0u8; 0x64];
|
||||
v[0x00..0x04].copy_from_slice(&404i32.to_le_bytes());
|
||||
v[0x04..0x08].copy_from_slice(&18_704i32.to_le_bytes());
|
||||
v[0x08..0x0c].copy_from_slice(&0i32.to_le_bytes());
|
||||
v[0x0c..0x10].copy_from_slice(&2_117_888i32.to_le_bytes());
|
||||
v[0x10..0x14].copy_from_slice(&2_192_576i32.to_le_bytes());
|
||||
v[0x14..0x18].copy_from_slice(&2_615_232i32.to_le_bytes());
|
||||
v[0x30..0x34].copy_from_slice(&93i32.to_le_bytes());
|
||||
v[0x34..0x38].copy_from_slice(&10_512i32.to_le_bytes());
|
||||
v[0x60..0x64].copy_from_slice(&3_518_848i32.to_le_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_measured_header() {
|
||||
let h = LevelCoreHeader::parse(&header()).unwrap();
|
||||
assert_eq!(
|
||||
h.gs_ram,
|
||||
ArrayRange {
|
||||
count: 404,
|
||||
offset: 18_704
|
||||
}
|
||||
);
|
||||
assert_eq!(h.tfrags, 0);
|
||||
assert_eq!(h.occlusion, 2_117_888);
|
||||
assert_eq!(h.sky, 2_192_576);
|
||||
assert_eq!(h.collision, 2_615_232);
|
||||
assert_eq!(
|
||||
h.tfrag_textures,
|
||||
ArrayRange {
|
||||
count: 93,
|
||||
offset: 10_512
|
||||
}
|
||||
);
|
||||
assert_eq!(h.textures_base, 3_518_848);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_header_is_an_error() {
|
||||
assert!(LevelCoreHeader::parse(&[0u8; 0x20]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_texture_table() {
|
||||
let mut index = vec![0u8; 10_512 + 2 * 16];
|
||||
let mut put = |at: usize, e: TextureEntry| {
|
||||
index[at..at + 4].copy_from_slice(&e.data_offset.to_le_bytes());
|
||||
index[at + 4..at + 6].copy_from_slice(&e.width.to_le_bytes());
|
||||
index[at + 6..at + 8].copy_from_slice(&e.height.to_le_bytes());
|
||||
index[at + 8..at + 10].copy_from_slice(&e.ty.to_le_bytes());
|
||||
index[at + 10..at + 12].copy_from_slice(&e.palette.to_le_bytes());
|
||||
index[at + 12..at + 14].copy_from_slice(&e.mipmap.to_le_bytes());
|
||||
};
|
||||
put(
|
||||
10_512,
|
||||
TextureEntry {
|
||||
data_offset: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
ty: 3,
|
||||
palette: 8,
|
||||
mipmap: 12,
|
||||
pad: -1,
|
||||
},
|
||||
);
|
||||
put(
|
||||
10_528,
|
||||
TextureEntry {
|
||||
data_offset: 20_480,
|
||||
width: 64,
|
||||
height: 64,
|
||||
ty: 3,
|
||||
palette: 60,
|
||||
mipmap: 64,
|
||||
pad: -1,
|
||||
},
|
||||
);
|
||||
|
||||
let t = texture_table(
|
||||
&index,
|
||||
ArrayRange {
|
||||
count: 2,
|
||||
offset: 10_512,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t.len(), 2);
|
||||
assert_eq!(t[0].width, 128);
|
||||
assert_eq!(t[1].mipmap, 64);
|
||||
// Widths are `width << 6` texels, so 128 px needs log2(8192) = 13.
|
||||
assert_eq!(t[0].log2_size(), 13);
|
||||
assert_eq!(t[1].log2_size(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_running_past_the_index_is_an_error() {
|
||||
let index = vec![0u8; 64];
|
||||
assert!(texture_table(
|
||||
&index,
|
||||
ArrayRange {
|
||||
count: 8,
|
||||
offset: 60
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
assert!(texture_table(
|
||||
&index,
|
||||
ArrayRange {
|
||||
count: -1,
|
||||
offset: 0
|
||||
}
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod buf;
|
||||
pub mod global_wad;
|
||||
pub mod iso9660;
|
||||
pub mod level_core;
|
||||
pub mod level_data;
|
||||
pub mod level_wad;
|
||||
pub mod tfrag;
|
||||
|
||||
@@ -36,8 +36,12 @@
|
||||
//! Strip indices address the vertex-info array rather than the position array —
|
||||
//! several vertex-info entries can share a position.
|
||||
//!
|
||||
//! Not yet recovered: the ad-gif texture bindings themselves, and the LOD
|
||||
//! switch and morph rules in `TfragProc`. See `docs/plan/tfrag.md`.
|
||||
//! Also recovered: the ad-gif texture primitives (texture index, wrap modes,
|
||||
//! minification filter, LOD bias), the per-vertex colours and the per-vertex
|
||||
//! light and normal records.
|
||||
//!
|
||||
//! Not yet recovered: the LOD switch and morph rules in `TfragProc`, and the
|
||||
//! texel data the texture table points into. See `docs/plan/tfrag.md`.
|
||||
|
||||
use crate::buf::{Buf, Error, ErrorKind, Result};
|
||||
|
||||
@@ -442,6 +446,144 @@ pub struct TfragTriangle {
|
||||
pub ad_gif: u16,
|
||||
}
|
||||
|
||||
/// One GIF A+D quadword (16 bytes) from an ad-gif.
|
||||
///
|
||||
/// On disc the register values are packed fields, not GS registers; the level
|
||||
/// load rewrites them in place before drawing.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AdGif {
|
||||
pub data_lo: i32,
|
||||
pub data_hi: i32,
|
||||
/// GS register address: 0x06 TEX0, 0x14 TEX1, 0x08 CLAMP, 0x34/0x36 MIPTBP.
|
||||
pub address: u8,
|
||||
}
|
||||
|
||||
/// A texture primitive: five A+D quadwords (0x50 bytes).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TfragAdGifs {
|
||||
/// TEX0_1. On disc `data_lo` indexes the level's tfrag texture table.
|
||||
pub tex0: AdGif,
|
||||
/// TEX1_1. On disc `data_lo` is the LOD K in 1/16 units, `data_hi` the MMIN filter.
|
||||
pub tex1: AdGif,
|
||||
/// CLAMP_1. On disc `data_lo` is WMS and `data_hi` is WMT.
|
||||
pub clamp: AdGif,
|
||||
pub miptbp1: AdGif,
|
||||
pub miptbp2: AdGif,
|
||||
}
|
||||
|
||||
/// GS CLAMP_1 wrap mode. Retail tfrags only use Repeat and Clamp.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GsWrap {
|
||||
Repeat,
|
||||
Clamp,
|
||||
RegionClamp,
|
||||
RegionRepeat,
|
||||
}
|
||||
|
||||
impl GsWrap {
|
||||
fn from_bits(v: i32) -> GsWrap {
|
||||
match v & 3 {
|
||||
0 => GsWrap::Repeat,
|
||||
1 => GsWrap::Clamp,
|
||||
2 => GsWrap::RegionClamp,
|
||||
_ => GsWrap::RegionRepeat,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GS TEX1_1 magnification/minification filter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GsFilter {
|
||||
Nearest,
|
||||
Linear,
|
||||
NearestMipmapNearest,
|
||||
NearestMipmapLinear,
|
||||
LinearMipmapNearest,
|
||||
LinearMipmapLinear,
|
||||
}
|
||||
|
||||
impl GsFilter {
|
||||
fn from_bits(v: i32) -> GsFilter {
|
||||
match v & 7 {
|
||||
0 => GsFilter::Nearest,
|
||||
1 => GsFilter::Linear,
|
||||
2 => GsFilter::NearestMipmapNearest,
|
||||
3 => GsFilter::NearestMipmapLinear,
|
||||
4 => GsFilter::LinearMipmapNearest,
|
||||
_ => GsFilter::LinearMipmapLinear,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TfragAdGifs {
|
||||
fn parse(bytes: &[u8]) -> TfragAdGifs {
|
||||
let q = |i: usize| AdGif {
|
||||
data_lo: i32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()),
|
||||
data_hi: i32::from_le_bytes(bytes[i + 4..i + 8].try_into().unwrap()),
|
||||
address: bytes[i + 8],
|
||||
};
|
||||
TfragAdGifs {
|
||||
tex0: q(0),
|
||||
tex1: q(16),
|
||||
clamp: q(32),
|
||||
miptbp1: q(48),
|
||||
miptbp2: q(64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index into the level's tfrag texture table.
|
||||
pub fn texture_index(&self) -> usize {
|
||||
self.tex0.data_lo.max(0) as usize
|
||||
}
|
||||
|
||||
/// Horizontal wrap mode.
|
||||
pub fn wrap_s(&self) -> GsWrap {
|
||||
GsWrap::from_bits(self.clamp.data_lo)
|
||||
}
|
||||
|
||||
/// Vertical wrap mode.
|
||||
pub fn wrap_t(&self) -> GsWrap {
|
||||
GsWrap::from_bits(self.clamp.data_hi)
|
||||
}
|
||||
|
||||
/// Minification filter, from the on-disc MMIN field.
|
||||
pub fn min_filter(&self) -> GsFilter {
|
||||
GsFilter::from_bits(self.tex1.data_hi)
|
||||
}
|
||||
|
||||
/// TEX1_1 K field as the signed 12-bit raw value, 1/16 units.
|
||||
pub fn lod_k_raw(&self) -> i16 {
|
||||
((self.tex1.data_lo as u32 & 0xfff) << 4) as u16 as i16 >> 4
|
||||
}
|
||||
|
||||
/// TEX1_1 K in mip levels. The GS computes `log2(1 / |Q|) + K`.
|
||||
pub fn lod_k(&self) -> f32 {
|
||||
self.lod_k_raw() as f32 / 16.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-vertex colour (4 bytes), indexed by position index. 0x80 is 1.0.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TfragRgba {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
/// 0x80 is opaque.
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
/// Per-vertex light and normal record (8 bytes), indexed by position index.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TfragLight {
|
||||
pub unknown_0: i8,
|
||||
pub intensity: i8,
|
||||
/// Normal azimuth, pi/128 radians per unit.
|
||||
pub azimuth: i8,
|
||||
/// Normal elevation, pi/128 radians per unit.
|
||||
pub elevation: i8,
|
||||
pub color: i16,
|
||||
}
|
||||
|
||||
/// Everything one tfrag contributes to a draw call.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct TfragGeometry {
|
||||
@@ -454,7 +596,12 @@ pub struct TfragGeometry {
|
||||
pub vinfo_tier: [usize; 3],
|
||||
/// Index 0 is highest detail.
|
||||
pub lod: [TfragLod; 3],
|
||||
pub ad_gif_count: usize,
|
||||
/// Texture primitives; strips select one by ad-gif index.
|
||||
pub ad_gifs: Vec<TfragAdGifs>,
|
||||
/// Per-vertex colours, indexed by position index.
|
||||
pub rgba: Vec<TfragRgba>,
|
||||
/// Per-vertex light and normal records, indexed by position index.
|
||||
pub lights: Vec<TfragLight>,
|
||||
}
|
||||
|
||||
impl TfragGeometry {
|
||||
@@ -547,7 +694,15 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
|
||||
have_vu = true;
|
||||
}
|
||||
// Ad-gifs: 5 quadwords each.
|
||||
(3, 0) => g.ad_gif_count = v.count() / 5,
|
||||
(3, 0) => {
|
||||
for i in 0..v.count() / 5 {
|
||||
let o = i * 0x50;
|
||||
if payload.len() < o + 0x50 {
|
||||
break;
|
||||
}
|
||||
g.ad_gifs.push(TfragAdGifs::parse(&payload[o..o + 0x50]));
|
||||
}
|
||||
}
|
||||
(2, 1) => {
|
||||
let tier = if addr == vu.positions_common_addr {
|
||||
0
|
||||
@@ -614,6 +769,23 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
|
||||
kind: ErrorKind::Invalid("tfrag has no VU header"),
|
||||
});
|
||||
}
|
||||
// These three arrays sit outside the VIF lists, at fixed offsets in the
|
||||
// patch's data block.
|
||||
g.rgba = read_rgba(
|
||||
block,
|
||||
header.rgba_ofs as usize,
|
||||
header.rgba_size as usize * 4,
|
||||
)?;
|
||||
g.lights = read_lights(
|
||||
block,
|
||||
header.light_ofs as usize + 0x10,
|
||||
header.vert_count as usize,
|
||||
)?;
|
||||
if !have_origin {
|
||||
// Without an STROW origin the quadword at light_ofs carries it.
|
||||
g.origin = read_i32x4(block, header.light_ofs as usize).unwrap_or([0; 4]);
|
||||
}
|
||||
|
||||
if header.vert_count != 0 && g.positions.len() != header.vert_count as usize {
|
||||
return Err(Error {
|
||||
offset: 0,
|
||||
@@ -621,10 +793,70 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
|
||||
kind: ErrorKind::Invalid("tfrag position count does not match its header"),
|
||||
});
|
||||
}
|
||||
let _ = have_origin;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
/// Read `count` 4-byte colour records at `at`.
|
||||
fn read_rgba(block: &[u8], at: usize, count: usize) -> Result<Vec<TfragRgba>> {
|
||||
let end = at
|
||||
.checked_add(count * 4)
|
||||
.filter(|&e| e <= block.len())
|
||||
.ok_or(Error {
|
||||
offset: at,
|
||||
context: "tfrag rgba array runs past the block",
|
||||
kind: ErrorKind::OutOfBounds {
|
||||
needed: count * 4,
|
||||
available: block.len().saturating_sub(at),
|
||||
},
|
||||
})?;
|
||||
Ok((at..end)
|
||||
.step_by(4)
|
||||
.map(|i| TfragRgba {
|
||||
r: block[i],
|
||||
g: block[i + 1],
|
||||
b: block[i + 2],
|
||||
a: block[i + 3],
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Read `count` 8-byte light records at `at`.
|
||||
fn read_lights(block: &[u8], at: usize, count: usize) -> Result<Vec<TfragLight>> {
|
||||
let end = at
|
||||
.checked_add(count * 8)
|
||||
.filter(|&e| e <= block.len())
|
||||
.ok_or(Error {
|
||||
offset: at,
|
||||
context: "tfrag light array runs past the block",
|
||||
kind: ErrorKind::OutOfBounds {
|
||||
needed: count * 8,
|
||||
available: block.len().saturating_sub(at),
|
||||
},
|
||||
})?;
|
||||
Ok((at..end)
|
||||
.step_by(8)
|
||||
.map(|i| TfragLight {
|
||||
unknown_0: block[i] as i8,
|
||||
intensity: block[i + 1] as i8,
|
||||
azimuth: block[i + 2] as i8,
|
||||
elevation: block[i + 3] as i8,
|
||||
color: i16::from_le_bytes([block[i + 4], block[i + 5]]),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Read four `i32` at `at`, or `None` if out of range.
|
||||
fn read_i32x4(block: &[u8], at: usize) -> Option<[i32; 4]> {
|
||||
if at.checked_add(16).is_none_or(|e| e > block.len()) {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0i32; 4];
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
*slot = i32::from_le_bytes(block[at + i * 4..at + i * 4 + 4].try_into().ok()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Read `count` V3_16 elements: three `i16` each, 6 bytes per element.
|
||||
fn read_i16x3(bytes: &[u8], count: usize) -> Result<Vec<[i16; 3]>> {
|
||||
if bytes.len() < count * 6 {
|
||||
@@ -712,7 +944,7 @@ pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result<Vec<TfragTriangl
|
||||
}
|
||||
if k == 0 || s.end_of_packet_flag >= 0 || s.ad_gif_offset >= 0 {
|
||||
let z = s.ad_gif_offset as i32;
|
||||
if z < 0 || z % 5 != 0 || (z / 5) as usize >= g.ad_gif_count {
|
||||
if z < 0 || z % 5 != 0 || (z / 5) as usize >= g.ad_gifs.len() {
|
||||
return Err(Error {
|
||||
offset: 0,
|
||||
context: "strip ad-gif offset outside the ad-gif array",
|
||||
@@ -1001,7 +1233,7 @@ mod topology_tests {
|
||||
|
||||
/// A common list holding only a V3_16 position unpack, plus the LOD-0
|
||||
/// strips and indices that reference it.
|
||||
fn fixture() -> (Vec<u8>, TfragHeader) {
|
||||
pub(super) fn fixture() -> (Vec<u8>, TfragHeader) {
|
||||
const INDICES: u16 = 6;
|
||||
const STRIPS: u16 = 8;
|
||||
let verts: [[i16; 3]; 6] = [
|
||||
@@ -1066,6 +1298,21 @@ mod topology_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// The tfrag origin, as the STROW row that precedes the positions.
|
||||
block.extend_from_slice(
|
||||
&VifCode {
|
||||
irq: false,
|
||||
cmd: 0x30,
|
||||
num: 0,
|
||||
imm: 0,
|
||||
}
|
||||
.to_word()
|
||||
.to_le_bytes(),
|
||||
);
|
||||
for c in [1024i32, 2048, 4096, 0] {
|
||||
block.extend_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
|
||||
// Positions.
|
||||
block.extend_from_slice(
|
||||
&VifCode {
|
||||
@@ -1140,6 +1387,27 @@ mod topology_tests {
|
||||
let lod0_end = block.len().div_ceil(4) * 4;
|
||||
block.resize(lod0_end, 0);
|
||||
|
||||
// Past the lists: the origin quadword and the light array.
|
||||
let lights_at = block.len().div_ceil(16) * 16;
|
||||
block.resize(lights_at, 0);
|
||||
for c in [0i32, 0, 0, 0] {
|
||||
block.extend_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
for i in 0..6u8 {
|
||||
let v = i as i8;
|
||||
let colour = (i as u16) * 100;
|
||||
block.extend_from_slice(&[
|
||||
i,
|
||||
v as u8,
|
||||
(v * 8) as u8,
|
||||
(v * 4) as u8,
|
||||
colour as u8,
|
||||
(colour >> 8) as u8,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
|
||||
let mut h = TfragHeader::parse(&[0u8; TFRAG_HEADER_SIZE]).unwrap();
|
||||
h.vert_count = 6;
|
||||
h.tri_count = 4; // two runs of 4 vertices
|
||||
@@ -1148,6 +1416,8 @@ mod topology_tests {
|
||||
h.lod_0_ofs = common_end as u16;
|
||||
h.lod_1_size = (common_end / 16) as u8;
|
||||
h.rgba_ofs = lod0_end as u16;
|
||||
h.light_ofs = lights_at as u16;
|
||||
h.light_end_ofs = (lights_at + 0x10 + 6 * 8) as u16;
|
||||
(block, h)
|
||||
}
|
||||
|
||||
@@ -1176,10 +1446,21 @@ mod topology_tests {
|
||||
let g = parse_tfrag(&block, &header).unwrap();
|
||||
assert_eq!(g.position_index(3), 3);
|
||||
assert_eq!(g.lod_vinfo_count(0), 6);
|
||||
// Origin from the STROW row, in 1024ths of a world unit.
|
||||
assert_eq!(g.origin, [1024, 2048, 4096, 0]);
|
||||
assert_eq!(
|
||||
g.world_position(0),
|
||||
[99.0 / 1024.0, -3795.0 / 1024.0, -5203.0 / 1024.0]
|
||||
[
|
||||
(1024 + 99) as f32 / 1024.0,
|
||||
(2048 - 3795) as f32 / 1024.0,
|
||||
(4096 - 5203) as f32 / 1024.0
|
||||
]
|
||||
);
|
||||
// Six positions, so six light records.
|
||||
assert_eq!(g.lights.len(), 6);
|
||||
assert_eq!(g.lights[3].intensity, 3);
|
||||
assert_eq!(g.lights[3].azimuth, 24);
|
||||
assert_eq!(g.lights[3].color, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1202,3 +1483,95 @@ mod topology_tests {
|
||||
assert!(tfrag_triangles(&g, 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ad_gif_tests {
|
||||
use super::*;
|
||||
|
||||
/// An ad-gif as measured in level 0: texture 12, K = -137/16, clamp, mip 4.
|
||||
fn measured() -> TfragAdGifs {
|
||||
TfragAdGifs {
|
||||
tex0: AdGif {
|
||||
data_lo: 12,
|
||||
data_hi: 0,
|
||||
address: 0x06,
|
||||
},
|
||||
tex1: AdGif {
|
||||
data_lo: 0xf77,
|
||||
data_hi: 4,
|
||||
address: 0x14,
|
||||
},
|
||||
clamp: AdGif {
|
||||
data_lo: 0,
|
||||
data_hi: 1,
|
||||
address: 0x08,
|
||||
},
|
||||
miptbp1: AdGif {
|
||||
data_lo: 0,
|
||||
data_hi: 0,
|
||||
address: 0x34,
|
||||
},
|
||||
miptbp2: AdGif {
|
||||
data_lo: 0,
|
||||
data_hi: 0,
|
||||
address: 0x36,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_the_texture_binding() {
|
||||
let a = measured();
|
||||
assert_eq!(a.texture_index(), 12);
|
||||
assert_eq!(a.wrap_s(), GsWrap::Repeat);
|
||||
assert_eq!(a.wrap_t(), GsWrap::Clamp);
|
||||
assert_eq!(a.min_filter(), GsFilter::LinearMipmapNearest);
|
||||
// K is a signed 12-bit value in 1/16 units.
|
||||
assert_eq!(a.lod_k_raw(), -137);
|
||||
assert_eq!(a.lod_k(), -137.0 / 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_k_survives_the_round_trip() {
|
||||
// The on-disc field is only 12 bits wide and is sign-extended through the
|
||||
// shift, so 0xf77 in 1/16 units is -137 rather than +3959.
|
||||
let a = TfragAdGifs {
|
||||
tex1: AdGif {
|
||||
data_lo: 0xf77,
|
||||
data_hi: 4,
|
||||
address: 0x14,
|
||||
},
|
||||
..measured()
|
||||
};
|
||||
assert_eq!(a.lod_k_raw(), -137);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_five_quadwords_into_register_order() {
|
||||
let mut bytes = Vec::new();
|
||||
for (i, addr) in [0x06u8, 0x14, 0x08, 0x34, 0x36].iter().enumerate() {
|
||||
bytes.extend_from_slice(&((i as i32 + 1) * 10).to_le_bytes());
|
||||
bytes.extend_from_slice(&((i as i32 + 1) * 100).to_le_bytes());
|
||||
bytes.push(*addr);
|
||||
bytes.extend_from_slice(&[0u8; 7]);
|
||||
}
|
||||
let a = TfragAdGifs::parse(&bytes);
|
||||
assert_eq!(a.tex0.data_lo, 10);
|
||||
assert_eq!(a.tex0.address, 0x06);
|
||||
assert_eq!(a.tex1.data_hi, 200);
|
||||
assert_eq!(a.clamp.data_lo, 30);
|
||||
assert_eq!(a.miptbp2.address, 0x36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_exposes_two_ad_gifs() {
|
||||
let (block, header) = super::topology_tests::fixture();
|
||||
let g = parse_tfrag(&block, &header).unwrap();
|
||||
assert_eq!(g.ad_gifs.len(), 2);
|
||||
assert_eq!(g.ad_gifs[1].texture_index(), 0);
|
||||
// Strip 1 selects ad-gif 1 via its quadword offset of 5.
|
||||
let tris = tfrag_triangles(&g, 0).unwrap();
|
||||
assert_eq!(tris[0].ad_gif, 0);
|
||||
assert_eq!(tris[2].ad_gif, 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user