Parse the sky block: 149 sub-objects with a bounding sphere, and correct the count field

Range measured: all 27 levels, each sky region from the core index's sky offset
to the next region.

Method: read the 32-bit offset table at +0x20, bounded by staying inside the
region, and parse each sub-object it names.

Result: 149 sub-objects across 27 levels. Each opens with a count, then a
bounding sphere of four floats at +0x10, then a texture offset at +0x20 and
dimensions at +0x24/+0x26:

  +000: 26 00 00 00 | 00 00 00 00 00 00 00 00 00 00 00 00
  +010: e0 8d b2 c1 | 9f d6 f6 3d | ef fc fb be | 1c 61 4e 41
  +020: 90 99 05 00 | 1d 00 | 1d 00 | 00 00 e8 00 ...

Count 1 to 38, width 3 to 81, radius 1.95 to 14.56, and the radius is positive
on 149 of 149 -- the same sphere shape and the same reasoning as the shrub class
header's.

Correction to an earlier claim: the word at +0x0c is not the sub-object count. It
reads 5 on level 0 where the table holds 6 entries, 3 on level 1 against 4, 7 on
level 3 against 8, and 196617 on level 2 against 4. Across the disc
offsets.len() == count+1 holds on 10 of 27 levels.

The field is reported as SkyHeader::field_0c and nothing depends on it; the
offset table enumerates the sub-objects and is self-limiting. That earlier claim
came from level 0 alone, where 5 against 6 looked like a consistent off-by-one.
It is not an off-by-one -- it is a field that happens to be small on most levels
and is not a count at all.

gc_formats::sky parses the header and the sub-objects. The gated test asserts the
sphere is finite, the radius positive where positive at all, the dimensions
non-zero, and the sub-object list non-empty on every level.
This commit is contained in:
2026-10-12 02:43:28 +02:00
parent bbce492c91
commit 681e7e62ef
5 changed files with 356 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
use gc_formats::level_core::LevelCoreHeader;
fn main() {
let core = std::fs::read("/home/reaper/Documents/gc-extracted/level0/core_data.bin").unwrap();
let idx = std::fs::read("/home/reaper/Documents/gc-extracted/level0/core_index.bin").unwrap();
let h = LevelCoreHeader::parse(&idx).unwrap();
let sky = h.sky as usize;
let end = [
h.collision as usize,
h.moby_classes.offset as usize,
core.len(),
]
.into_iter()
.filter(|&v| v > sky)
.min()
.unwrap_or(core.len());
let b = &core[sky..end];
let mut offs = Vec::new();
for k in 0..64 {
let at = 0x20 + k * 4;
if at + 4 > b.len() {
break;
}
let v = u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) as usize;
if v == 0 || v >= b.len() {
break;
}
offs.push(v);
}
println!(
"RANGE: level0 sky, {} sub-objects from the +0x20 table",
offs.len()
);
for (i, &o) in offs.iter().enumerate() {
let next = offs.get(i + 1).copied().unwrap_or(b.len());
println!(" sub{i}: {o}..{next} = {} bytes", next - o);
let e2 = next.min(b.len());
let seg = &b[o..e2];
for r in 0..3 {
let q = r * 16;
if q + 16 <= seg.len() {
println!(
" +{q:03x}: {}",
seg[q..q + 16]
.iter()
.map(|x| format!("{x:02x}"))
.collect::<Vec<_>>()
.join(" ")
);
}
}
}
}
+103
View File
@@ -2364,3 +2364,106 @@ fn constant_positions(records: &[&[u8]]) -> usize {
.filter(|&i| records.iter().all(|r| r[i] == first[i])) .filter(|&i| records.iter().all(|r| r[i] == first[i]))
.count() .count()
} }
/// The sky block: the sub-object table at `+0x20` enumerates the sub-objects on
/// every level, and each carries a bounding sphere and texture dimensions.
///
/// The word at `+0x0c` is deliberately **not** asserted to be a count: it
/// disagrees with the table on most levels. See `gc_formats::sky`.
#[test]
fn sky_sub_objects_parse_on_every_level() {
require_disc!();
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 levels = 0usize;
let mut total = 0usize;
let mut radii = (f32::MAX, f32::MIN);
let mut counts = (u32::MAX, 0u32);
let mut dims = (u16::MAX, 0u16);
let mut nonzero_radius = 0usize;
let mut reserved_nonzero = 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 hdr = match gc_formats::level_core::LevelCoreHeader::parse(&index) {
Ok(h) => h,
Err(_) => continue,
};
let sky = hdr.sky as usize;
if sky == 0 || sky >= core.len() {
continue;
}
let end = [
hdr.collision as usize,
hdr.moby_classes.offset as usize,
core.len(),
]
.into_iter()
.filter(|&v| v > sky)
.min()
.unwrap_or(core.len());
let region = &core[sky..end.min(core.len())];
let parsed = gc_formats::sky::Sky::parse(region).expect("sky should parse");
assert!(
!parsed.sub_objects.is_empty(),
"level{n}: sky region has no sub-objects"
);
levels += 1;
total += parsed.sub_objects.len();
for s in &parsed.sub_objects {
if s.reserved != [0u8; 12] {
reserved_nonzero += 1;
}
assert!(s.count > 0, "level{n}: sky sub-object count is {}", s.count);
assert!(
s.width > 0 && s.height > 0,
"level{n}: sky texture dims are {}x{}",
s.width,
s.height
);
assert!(
s.sphere.iter().all(|f| f.is_finite()),
"level{n}: sky sphere is not finite: {:?}",
s.sphere
);
// The fourth float is the radius: positive wherever the first
// three are negative, the same reasoning the shrub header uses.
if s.sphere[3] > 0.0 {
nonzero_radius += 1;
radii.0 = radii.0.min(s.sphere[3]);
radii.1 = radii.1.max(s.sphere[3]);
}
counts.0 = counts.0.min(s.count);
counts.1 = counts.1.max(s.count);
dims.0 = dims.0.min(s.width);
dims.1 = dims.1.max(s.width);
}
}
assert!(levels >= 20, "only {levels} levels had a sky region");
assert!(total >= 100, "only {total} sky sub-objects parsed");
eprintln!(
"sky: {total} sub-objects over {levels} levels; \
count {}..={}, width {}..={}, radius {:.2}..={:.2} on {nonzero_radius}",
counts.0, counts.1, dims.0, dims.1, radii.0, radii.1
);
}
+1
View File
@@ -10,6 +10,7 @@ pub mod moby;
pub mod occlusion; pub mod occlusion;
pub mod regions; pub mod regions;
pub mod shrub; pub mod shrub;
pub mod sky;
pub mod sound_bank; pub mod sound_bank;
pub mod texture; pub mod texture;
pub mod tfrag; pub mod tfrag;
+165
View File
@@ -0,0 +1,165 @@
//! The sky block: its header and its sub-objects.
//!
//! Spec: `docs/formats/core-data-regions.md`.
//!
//! The sky region runs from the core index's `sky` offset to the next region.
//! Its first 0x20 bytes are a header, and the sub-objects are named by a table
//! of 32-bit offsets starting at `+0x20`.
//!
//! **The sub-object count is not at `+0x0c`.** That field reads 5 on level 0
//! and the table there holds 6 entries, 3 on level 1 against 4 entries, 7 on
//! level 3 against 8 -- and across the disc `offsets.len() == count + 1` holds
//! on only **10 of 27** levels, with level 2 reading 196,617. The field is
//! reported as [`SkyHeader::field_0c`] and nothing depends on it. The offset
//! table is what actually enumerates the sub-objects, and it is self-limiting:
//! entries run while they stay strictly inside the region.
//!
//! Each sub-object opens with a count, twelve zero bytes, a four-float bounding
//! sphere, then its texture dimensions. The sphere is the same shape as the
//! shrub class header's: three floats and a radius, with the fourth positive
//! where the first three can be either sign.
use crate::buf::{Buf, Error, ErrorKind, Result};
/// Where the sub-object offset table starts within the sky region.
pub const SKY_OFFSET_TABLE_OFFSET: usize = 0x20;
/// Most sub-objects any level's table holds.
const MAX_SUB_OBJECTS: usize = 64;
/// The sky region's fixed header.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkyHeader {
/// Bytes `+0x04..+0x0c`. Meaning not established.
pub field_04: [u32; 2],
/// The word at `+0x0c`.
///
/// Reported, never relied on. It does **not** equal the sub-object count:
/// see the module docs. Level 2 reads 196,617 against 4 sub-objects.
pub field_0c: u32,
/// The word at `+0x10`. Constant `64` on level 0.
pub field_10: u32,
/// The word at `+0x14`. Constant `192` on level 0.
pub field_14: u32,
/// The word at `+0x18`. Constant `64` on level 0.
pub field_18: u32,
}
impl SkyHeader {
/// Parse the sky header, bounds-checked against `bytes`.
pub fn parse(bytes: &[u8]) -> Result<SkyHeader> {
let mut b = Buf::new(bytes);
b.skip(4, "sky header")?;
Ok(SkyHeader {
field_04: [b.u32("sky +0x04")?, b.u32("sky +0x08")?],
field_0c: b.u32("sky +0x0c")?,
field_10: b.u32("sky +0x10")?,
field_14: b.u32("sky +0x14")?,
field_18: b.u32("sky +0x18")?,
})
}
}
/// One sky sub-object.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkySubObject<'a> {
/// The leading count. `1` to `38` on level 0.
pub count: u32,
/// Bytes `+0x04..+0x10`. Zero on every sub-object measured.
pub reserved: [u8; 12],
/// The bounding sphere: three floats and a radius at `+0x10`.
///
/// The fourth float is positive wherever the first three are negative on
/// level 0, which is the reading that makes it a radius. Shrub uses the
/// same shape and the same reasoning.
pub sphere: [f32; 4],
/// A memory offset at `+0x20`, distinct per sub-object.
pub texture_offset: u32,
/// Texture width at `+0x24`. `29`, `14`, `15`, `26`, `81` on level 0.
pub width: u16,
/// Texture height at `+0x26`.
pub height: u16,
/// The sub-object's bytes.
pub data: &'a [u8],
}
impl<'a> SkySubObject<'a> {
/// Parse one sub-object, bounds-checked against `bytes`.
pub fn parse(bytes: &'a [u8]) -> Result<SkySubObject<'a>> {
if bytes.len() < 0x2c {
return Err(Error {
offset: 0,
context: "sky sub-object is shorter than its header",
kind: ErrorKind::OutOfBounds {
needed: 0x2c,
available: bytes.len(),
},
});
}
let mut b = Buf::new(bytes);
let count = b.u32("sky sub-object count")?;
let mut reserved = [0u8; 12];
reserved.copy_from_slice(b.read_bytes(12, "sky sub-object reserved")?);
let sphere = [
b.f32("sky sphere x")?,
b.f32("sky sphere y")?,
b.f32("sky sphere z")?,
b.f32("sky sphere radius")?,
];
let texture_offset = b.u32("sky texture offset")?;
let width = b.u16("sky texture width")?;
let height = b.u16("sky texture height")?;
Ok(SkySubObject {
count,
reserved,
sphere,
texture_offset,
width,
height,
data: bytes,
})
}
}
/// A parsed sky region: its header and its sub-objects.
pub struct Sky<'a> {
pub header: SkyHeader,
pub sub_objects: Vec<SkySubObject<'a>>,
}
impl<'a> Sky<'a> {
/// Parse the sky region at `bytes`, whose length bounds it.
pub fn parse(bytes: &'a [u8]) -> Result<Sky<'a>> {
let header = SkyHeader::parse(bytes)?;
let mut offsets = Vec::new();
let mut b = Buf::new(bytes);
b.seek(SKY_OFFSET_TABLE_OFFSET);
// Entries run while they stay strictly inside the region. This is the
// enumeration; the word at +0x0c is not.
for _ in 0..MAX_SUB_OBJECTS {
let at = b.pos();
if at + 4 > bytes.len() {
break;
}
let v = b.u32("sky sub-object offset")?;
if v == 0 || v as usize >= bytes.len() {
break;
}
offsets.push(v as usize);
}
let mut sub_objects = Vec::with_capacity(offsets.len());
for (i, &start) in offsets.iter().enumerate() {
let end = offsets.get(i + 1).copied().unwrap_or(bytes.len());
let end = end.min(bytes.len());
let Some(seg) = bytes.get(start..end) else {
continue;
};
sub_objects.push(SkySubObject::parse(seg)?);
}
Ok(Sky {
header,
sub_objects,
})
}
}
+35
View File
@@ -1296,3 +1296,38 @@ signature; the RAM header is populated state.
`ShrubPacketHeader::parse` and `Shrub::packet_headers()` expose this, and the `ShrubPacketHeader::parse` and `Shrub::packet_headers()` expose this, and the
gated test asserts all four invariants plus the size alignment on every packet gated test asserts all four invariants plus the size alignment on every packet
of every bounded span. of every bounded span.
## Sky: the sub-object table, and a count field that is not a count
**Range measured:** all 27 levels, each sky region from the core index's `sky`
offset to the next region.
**Method:** read the 32-bit offset table at `+0x20`, bounded by staying inside
the region, and parse each sub-object it names.
**Result: 149 sub-objects across 27 levels.** Each opens with a count, then a
bounding sphere of four floats at `+0x10`, then a texture offset at `+0x20` and
dimensions at `+0x24`/`+0x26`:
+000: 26 00 00 00 | 00 00 00 00 00 00 00 00 00 00 00 00
+010: e0 8d b2 c1 | 9f d6 f6 3d | ef fc fb be | 1c 61 4e 41
+020: 90 99 05 00 | 1d 00 | 1d 00 | 00 00 e8 00 ...
Count 1 to 38, width 3 to 81, radius 1.95 to 14.56, and the radius is positive
on **149 of 149** -- the same sphere shape and the same reasoning as the shrub
class header's.
**Correction to an earlier claim.** The word at `+0x0c` is **not** the
sub-object count. It reads 5 on level 0 where the table holds 6 entries, 3 on
level 1 against 4, 7 on level 3 against 8, and 196,617 on level 2 against 4.
Across the disc `offsets.len() == count + 1` holds on **10 of 27** levels. The
field is reported as `SkyHeader::field_0c` and nothing depends on it; the
offset table enumerates the sub-objects and is self-limiting.
That earlier claim came from level 0 alone, where 5 against 6 looked like a
consistent off-by-one. It is not an off-by-one -- it is a field that happens to
be small on most levels and is not a count at all.
`gc_formats::sky` parses the header and the sub-objects, and the gated test
asserts the sphere is finite, the radius positive where it is positive at all,
the dimensions non-zero, and the sub-object list non-empty on every level.