Commit Graph
61 Commits
Author SHA1 Message Date
ReaperOfVeriod 282e633a7c Withdraw the collision anchor: it was a byte-order bug in my own search
Last commit claimed exactly one GP-relative occurrence of the collision assert
string's $lo half, at 0x354cc8, and called it an unambiguous anchor. That was
wrong, and building the disassembler is what proved it.

The search compared word bytes 1..3 against the 16-bit immediate. The PS2's MIPS
is little-endian, so an I-type immediate sits in bits 15..0 -- word bytes 0 and
1. Bytes 1..3 are the opcode, $rs and $rt fields. The match was on those.

The instruction at the phantom hit is 0x24a89948:

    addiu $t0, $a1, -0x66b8

whose real immediate is 0x9948 -- ten bytes from the 0x99a8 being searched for.
-0x66b8 merely looks like a plausible $gp offset, which is what made it
convincing. With the byte order corrected the count is ZERO, in 2,751,268 bytes.

So the anchor is not established and the assertion is withdrawn. What survives is
still worth having: the functions are named (Camera_CollPrimTest takes (prim,
grid)), $gp = 0x1AEFF0, .lit loads at 0x1A7C80, and the assert string is a unique
copy at overlay+0xd18.

Probably the code reaching that string does not address it through $gp, or
reaches a copy in another section. The decomp's assert-message-names.tsv gives
0x002db350 for level 0 -- inside .lit, but it disassembles to a function epilogue
(jr $ra / addiu $sp, $sp, 0x70), so those are code locations, not string
addresses, and they are not traced to the assert either.

tools/overlay-anchors now does the search with the correct byte order and
carries a comment on exactly the part that was wrong, because that is the part
that will be re-derived otherwise. It also disassembles around an address:

    cargo run -q -p gc-overlay-anchors -- <overlay.bin> --disasm 0x2db350 0 64

The useful general lesson, which the roadmap already says and this is a second
demonstration of: a probe that returns a small, clean, narratively satisfying
number is the most dangerous kind of wrong result. "Exactly one hit in 2.75 MB"
read as conclusive. It was an artifact of comparing the wrong two bytes, and it
survived a commit because it fit the story.
2026-10-13 07:10:17 +02:00
ReaperOfVeriod 1f903efe23 An external decomp turns two overlay constants from measured into named
OpenRAC/rac2-gc-decomp is the same disc as ours, verified rather than assumed:

  ours    3,828,350,976  9db2e33e...b1ce5
  theirs  3,828,350,976  9db2e33e...b1ce5

Vendored read-only under vendor/ (already gitignored) and added to PROVENANCE.

It names two constants this parser had measured but never explained:

  * overlay.bin +0x00 = 1,735,808 = 0x1A7C80, the .lit load base. It is constant
    across all 27 levels because every level's literal section loads at the same
    address. So the first header word is the load base of the section the file IS,
    which also establishes that overlay.bin is that section -- it holds both code
    and the debug string pool. $gp = 0x1AEFF0 comes with it, and that closes the
    roadmap's open "$gp for this game" item.

The program around it is seven sections -- .lit .bss .data vtbl camvtbl sndvtbl
.text -- which is precisely the "seven-section parser and relocation" Phase 3
item for this format. Known target now rather than a guess.

I then tested the obvious follow-up and it is wrong: if +0x00 is the .lit load
base, +0x0c ought to be the .lit size. It is SMALLER than the file on 16 of 27
levels, by up to 8.9% (level11: file 3,151,128 vs 2,870,792). Excluded by sign,
not by a near miss. Still unnamed.

Collision: still blocked, but no longer blocked on tooling. The decomp names
Camera_CollPrimTest(void *prim, void *grid) without decompiling it, so the cell
layout is not handed over. What it does supply is $gp, and that is enough to
start. "Camera_CollPrimTest WARNING! - grid out of bounds! (RAR)" is at
overlay+0xd18, the only copy in the file, so:

    string  overlay+0xd18 -> RAM 0x1a8998;  delta to $gp -0x6658;  $lo 0x99a8

MIPS puts the $lo half in bytes 1..3 of a 4-byte instruction. Searching the whole
overlay instruction-aligned gives exactly ONE hit:

    0x354cc8    (overlay+0x1ad048)

One occurrence in 2.75 MB of code is unambiguous: that is the instruction
materialising the assert string, and its enclosing function is
Camera_CollPrimTest. That is the route in, and it needs no symbols.

tools/overlay-anchors does this step, dependency-free. I started this with
capstone for the disassembly proper and dropped it: capstone 0.14 does not
re-export Mode/Endian from a public path, and an excluded-from-the-workspace
native dependency is a poor trade for a step that does not need one yet. The
anchor search is pure byte work, so it is pure Rust. No native dependency is
added anywhere, and the tool is an ordinary workspace member.

Surveyed the rest of that project against our open items: no volume structures,
no tie vertex layouts, no SBlk/DAW fields, no wavelet tables. It is a guide to
the code, not a replacement for reading it.
2026-10-13 07:07:21 +02:00
ReaperOfVeriod 9f938f47de Assert the tie vertex layout finding in a test, and audit which claims are asserted
The i16[3] high-byte finding -- the most recent and most subtle result of this
stretch, and the one I corrected twice -- lived only in prose. Two commits is
exactly how long a claim can sit unasserted before it drifts. It now has a test:

  tie_vertex_layout_is_selected_by_i16_3_high_byte

which reproduces the figures exactly and, importantly, asserts both directions:
(4,5) and (6,7) are above 0.85 and 0.80 in the ordinary group, (6,7) is BELOW 0.99
so it cannot silently become the constant I once mistook it for, (0,1) and (2,3)
are under 0.20, and every pair in the non-zero group is under 0.15. A future
change in either layout now fails loudly.

Also audited the rest of this session's claims against the test suite and recorded
the result in the format doc:

  tie vertex element is 16 bytes             asserted
  layout selected by i16[3] HIGH byte       asserted (this commit)
  blocks self-delimiting on 0xf6            asserted per-block
  packets == 0 means no block at that LOD   asserted
  packets[0] == 256 on 1,864 of 1,864       asserted
  chunk record length uniform per chunk      asserted
  moby class IDs in one body slot per type  asserted

  collision payload verbatim in RAM         NOT assertable here -- needs a savestate
  tfrag patch bytes all rewritten by LOD    same
  morphing is unobservable in that image    same

The split is deliberate: disc-readable claims are asserted against the disc,
savestate-dependent ones live in plan/pcsx2-verification.md with their method.
What is not acceptable is a disc-readable claim living only in prose -- which is
how the tie i16[3] finding spent two commits before it had a test, and how several
older claims drifted before the roadmap reconciliation caught them.
2026-10-13 06:54:52 +02:00
ReaperOfVeriod 922248a4ec Tie: the element layout differs by i16[3] high byte, and (6,7) is a real pair
Two results, one of them a correction.

1. "(6,7) is just the constant 4096/0 pair, not a texcoord" -- wrong. It looked
   like an artifact because a constant is trivially a pair of multiples of 4096.
   But partitioned by i16[3]'s high byte it is a genuine fixed-point pair in its
   own right:

     i16[3] high byte ZERO (n = 89,341)        divisor rate by slot pair
         pair         /1      /4     /16    /256
         ( 0, 1)   100.0%  11.5%   4.6%   3.3%   baseline
         ( 2, 3)   100.0%  11.0%   4.5%   3.3%   baseline
         ( 4, 5)   100.0%  92.9%  92.0%  91.8%   the texcoords
         ( 6, 7)   100.0%  89.5%  87.3%  86.5%   a SECOND fixed-point pair

   If (6,7) were a constant it would score 100%, not 89.5%. The constant is the
   common case WITHIN it, not the whole of it -- which is exactly the distinction
   the constant-artifact lesson is about, and I applied it backwards last time.

   So the ordinary element carries TWO fixed-point pairs, (4,5) and (6,7), with
   (0,1) and (2,3) at baseline. What (6,7)'s role is remains open -- second UV
   set, normal, or a zero-heavy fixed-point pair all fit.

2. The element LAYOUT differs between the three cases. Scoring all four slot
   pairs within the non-zero high-byte group, no pair stands out:

     i16[3] high byte NON-ZERO (n = 6,271)
         pair         /1      /4     /16    /256
         ( 0, 1)   100.0%   4.6%   3.5%   0.7%
         ( 2, 3)   100.0%   2.0%   0.6%   0.0%
         ( 4, 5)   100.0%   1.9%   0.3%   0.1%
         ( 6, 7)   100.0%   7.5%   5.7%   4.5%

   Best is 7.5% against a 2-5% baseline. So for those 6,271 vertices the 16 bytes
   are not laid out as position/texcoord/normal at all -- a different element
   shape, selected by the high byte.

This also explains why the global slot-pair score looked ambiguous to begin with:
pooling the two layouts averages a 92% pair and a 7% pair into one number that
describes neither. Fourth time this session that partitioning produced the answer
rather than merely bounding it.
2026-10-13 06:53:22 +02:00
ReaperOfVeriod ed3e85a0f2 Refine the tie texcoord finding: it is i16[3]'s HIGH byte that selects
The previous commit said i16[3] selects the texcoord encoding. That was right but
loose -- it is specifically the HIGH BYTE, and the low byte is a separate thing.

Grouped by the high byte across the same 95,612 vertices on levels 0-2:

  i16[3] high byte        n        % / 16      % / 256
  0x00                89,341        92.0%        91.8%
  0x03                 5,344         0.0%         0.0%
  0x04                    71         0.0%         0.0%
  0x40                    58         5.2%         0.0%
  0x02                    47         2.1%         2.1%
  0x19, 0x05, 0x20, ...  the remaining 73

A high byte of zero is the ordinary case -- 89,341 of 95,612, 93.4% -- at 92%.
EVERY other high byte scores at or near zero. The split is on the byte, not on the
value: i16[3] of 1 and of 771 differ only in the high byte and behave completely
differently.

Within the all-zero case there is a third distribution: i16[3] == 0 exactly (5,851
vertices) scores 62% at 16 and 61% at 256, against 92% for 1..255.

So three cases, selected by the high byte and then the low:

  high 0x00, low 0x00    5,851    a third distribution, 62% at 1/16
  high 0x00, low 1..255 83,490    the ordinary case, 92% at 1/4096
  high non-zero         6,271     0%, an encoding this reading does not see

The 6,271 matches the figure quoted last commit for "vertices using an encoding
this reading does not see", and the two group counts sum consistently with the
committed test's 95,612 -- so this refines the finding rather than changing it.

A measurement trap worth recording: the all-zero case is a SUBSET of high byte
0x00, so summing the per-high-byte groups double-counts it. An intermediate total
came out at 100,792 against the true 94,941 for those groups; the committed test's
figure is the one to trust.
2026-10-13 06:50:08 +02:00
ReaperOfVeriod e27c40e738 Explain the tie texcoord residual: i16[3] selects the encoding
The texcoords are 12-bit fixed on 82.5% of vertices, not 100%, and the residual
had no explanation. Grouped by the vertex's i16[3] slot across 95,612 vertices on
levels 0-2, the buckets separate completely:

  i16[3]          n        % / 4096      % / 256
  negative        333        1.2%          1.2%
  == 0          5,851       29.2%         60.7%
  1..63        28,884       92.7%         94.3%
  64..255      54,606       92.3%         93.8%
  256..511          8        0.0%          0.0%
  512..1023      5,391        0.0%          0.0%
  >= 1024         539        0.2%          0.2%

The residual is fully accounted for. 87.3% of vertices sit in 1..255 at about 92.5%
fixedness, contributing 0.873 * 0.925 = 80.8 points; the 5,851 at i16[3] == 0
contribute 1.8 more. That is the 82.5% measured, to the point.

The remaining ~6% are not 12-bit fixed at all. i16[3] >= 256, and negatives, score
0% at BOTH 4096 and 256 -- 6,271 vertices in an encoding this reading does not
see. And i16[3] == 0 scores 29.2% at 4096 but 60.7% at 256, which is a third
distribution rather than a weaker version of the same one.

What this establishes: i16[3] is a per-vertex packed attribute whose value SELECTS
HOW the texcoord pair is stored, rather than an independent field sitting beside
the texcoords. 1-255 is the ordinary case, 0 is special, 256-and-up and negatives
are a different encoding. What those three cases MEAN is still open -- texture
index, normal index, flag or a combination -- but it is now a field whose role is
known, which is more than "packed word".

Worth noting the shape of the claim this replaces: "the texcoords are 12-bit fixed
at i16[4]/i16[5], 73.6% of the time, unexplained". The field position was right;
the 73.6% was a population average over three encodings. Partitioning by the one
variable that separates them turns a mystery into a lookup -- rule 3a again, and the
third time this session that partitioning a mystery produced the answer rather than
merely bounding it.
2026-10-13 05:19:06 +02:00
ReaperOfVeriod d56b439502 Exclude the collision segmentation theories on the full population, and name the
unblock

Continuing on the verbatim payload with the grid known.

Cells are not zero-separated. Counting maximal runs of non-zero u16 across the
whole payload:

  level 0   363 cells     15,115 runs     mean run 25.3 u16
  level 1   384 cells     24,647 runs     mean run 51.5 u16
  level 2    53 cells     31,512 runs     mean run 30.3 u16

Two to three orders of magnitude apart, so a zero run is not a cell boundary.
Cells are not uniform blocks either: level 0 averages 2,488 bytes per cell and
level 2 averages 43,684, which cannot both be one fixed-size record.

The run lengths are not records. Their histogram has a long tail of
near-power-of-two values -- 2,140, 1,184, 840, 816, 628, 604, 562 -- which is
what you get when a run is bounded by a single zero byte inside otherwise dense
data, not by a delimiter. With the 18%-zeros finding, the payload reads as dense
data with sparse holes rather than a delimited sequence.

So all three segmentation theories are now excluded on the full population rather
than on a sample: not ascending u32 tables, not zero-run delimiters, not fixed
stride.

What would actually unblock this: overlay.bin IS the game's own code, and the
collision parser is in it. Camera_CollPrimTest WARNING! - grid out of bounds!
(RAR) was located in the debug string pool earlier, and that string is inside the
collision routine. So the format is recoverable -- but not by measuring bytes. It
needs disassembly of that routine, or an R&C2 reference for the collision cell
record.

Stating that plainly is worth more than another scan. The disc-side measurement
space for collision is exhausted: the bytes are available, verbatim, the grid is
known, and three independent segmentation theories have failed on the whole
population. Further probing without the loader's own logic will not converge.
2026-10-13 04:46:10 +02:00
ReaperOfVeriod 338af9b7bd Fix the collision grid dims, and disprove two sample artifacts in the payload
With the payload confirmed verbatim in RAM, the disc bytes can be read directly.
The grid dimensions are the first two u16 of the payload: level 0 is 11 x 33 =
363 cells, level 1 24 x 16 = 384, level 2 1 x 53 = 53. Level 2 being one cell wide
is worth noting -- the grids are not all similar in shape.

Two readings disproved, both by measuring the whole payload instead of a sample.

An ascending u32 offset table. A probe from payload +1024 reads f4ae0000 40af0000
c4b2000020b20000, which is three ascending values before the fourth goes down
(0xb2c4 to 0xb220). Read as a table, that was a three-value impression. Scanning
all three levels for maximal ascending u32 runs of 8 or more entries finds zero,
on every level.

A global alternating-zero stride. The head of level 0 shows an obvious (value, 0)
pattern from about i16[37]:

  [32..] [1808, 1872, 2198, 0, 34, 86, 25848, 1, 25824, 1,
         -13028, 0, -12284, 0, -12228, 0, -12716, 0, ...]

Measured over the whole payload, both alignments score far below what that sample
suggests: even-slot aligned 18% second-half zero, odd-slot aligned 13%. The
sample again.

What the global numbers do say is that the payload is heavily zero-bearing -- 18%
against a chance rate of about 0.0015% -- which is what a sparse or variable-length
encoding looks like, and consistent with the variable-length reading established
earlier. The 18-against-13 split is a real alignment preference but a weak one.

The cell layout is still undecoded. The useful position now is that the bytes can
be read as they are and that they are sparse and variable-length rather than
fixed-stride, so the next attempt should look for a terminator-delimited walk
through the sparse region rather than for a record size.
2026-10-13 04:44:09 +02:00
ReaperOfVeriod 180fc27b04 Locate the tie texcoord field at i16[4]/i16[5], and rule out the (6,7) artifact
Scoring each adjacent i16 slot pair of the 16-byte tie vertex for 12-bit
fixedness over 36,501 level-0 vertices locates the fields:

  (i16[0], i16[1])    1.1%   not a texcoord
  (i16[2], i16[3])    4.8%   not a texcoord
  (i16[4], i16[5])   73.6%   <- the texcoords
  (i16[6], i16[7])   76.6%   <- the near-constant 4096/0 pair

(6,7) scores HIGHER and is not a texcoord. It is the near-constant 4096, 0 pair:
4096 is 1.0 in 12-bit fixed and 0 is zero, so both are trivially multiples of
4096. That is the constant artifact again -- a fixed pair would score 100%
anywhere. The field is (4,5), with (0,1) and (2,3) ruled out at 1.1% and 4.8%.

The first four slots span the full i16 range with 10,803 to 13,421 distinct values
each, so they are neither texcoords nor small fields.

The vertex base is confirmed to be vert_ofs alone. Adding the shader or control
region makes it worse --

  vert_ofs only                               73.6%
  bfc + vert_ofs                              69.8%
  bfc + control_count*control_size + vert_ofs  66.7%

-- so the 73.6% residual is not a mislocated base. Worth recording because
vert_ofs (3..7) numerically overlaps the shader region (5*shader_count, 5..20
quadwords), which made a combined base look plausible; measurement says the
simple one is right.

The test asserts (4,5) above 60% and (0,1)/(2,3) below 5%/15%, and names the
(6,7) artifact in its failure message so the higher-scoring pair cannot be
mistaken for the answer later.
2026-10-13 04:30:45 +02:00
ReaperOfVeriod 35b087c882 Establish the tie vertex element is 16 bytes, and correct the roadmap's tie entry
With TieRecord identified as RAC1's TiePacketHeader, each record's leading word
points at its packet in the block payload. Following it and stepping vert_size
elements from vert_ofs reaches the vertices: 95,612 of them on levels 0-2.

Element size settled by comparison, not assumed. Requiring the texcoord pair to be
12-bit fixed -- both words multiples of 4096, which is how 0x1000 = 1.0 reads:

  element 16 bytes : 78,919 / 95,612 = 82.5%
  element  8 bytes : 45,661 / 95,612 = 47.8%
  element 12 bytes : 44,834 / 95,612 = 44.8%

Sixteen wins by a factor of 1.7 over the nearest alternative.

Observed layout, 8 x i16: the first three slots span the full i16 range with
10,803-13,421 distinct values each; i16[4] and [5] are the texcoords and are both
multiples of 4096 on 82.5%; i16[6] reads 4096 on 85.8% and i16[7] reads 0 on
84.0%.

Not established: what any field means, and why the texcoord rate is 82.5% rather
than 100%. The residual tracks record shape -- (shaders 2, control 7, size 2)
hits 60%, (shaders 1, control 18, size 5) hits 89% -- so it is not one bad block
but something per-packet this reading does not capture.

Worth recording that this is the OPPOSITE of what the disc alone concluded two
commits ago. Reading the header word at +0x00 as an element count gave 0 of 922
blocks dividing; it is the byte offset to the payload, so the table it bounds has
16-byte records. The number was right and the reading was wrong, which is what
the packet-header identification corrected.

The roadmap's tie entry was also stale -- it still said "a 16-byte record is
withdrawn", which the packet-header work had already reversed. Rewritten from the
commits: both indirections, self-delimiting blocks, the TiePacketHeader
identification with its 15,219/15,219 rule, the 16-byte vertex element, and what
remains open. A test asserts the element size against both alternatives so the
82.5% figure cannot be quietly replaced by an assumption.
2026-10-13 04:27:54 +02:00
ReaperOfVeriod 54862b64ee Identify the tie record as RAC1's TiePacketHeader, and retract a formula
The 16-byte tie record is R&C1's TiePacketHeader offset for offset: a data
pointer at +0, then R&C1's byte fields at +4 through +0x10. The identification
is not a resemblance but R&C1's own rule -- bfc_distance == 5 * shader_count --
holding on all 15,219 records of all 2,177 blocks on all 27 levels.

Measured over the same set, and reported rather than asserted where it is not
universal:

  bfc_distance == 5 * shader_count      15219 / 15219
  vert_ofs <= vert_size                 all
  scissor_size <= scissor_ofs           all
  control_size <= control_count         all
  color_ofs == vert_size                12640 / 15219
  color_ofs == vert_ofs + vert_size    0 / 15219
  color_count, strip_count, strip_vertex_count zero  15089 / 15077 / 15078

R&C1's rule for the colour offset holding on none of them is the interesting
line: the two games use that field differently, so it is named color_ofs for
what this game's majority reads as and the 2,579 exceptions are counted rather
than averaged away. TieRecord parses all twelve fields, invariants_hold asserts
only the universal four, and the disc test counts the rest.

A packet-length formula is withdrawn rather than softened. (vert_ofs +
vert_size + scissor_ofs + scissor_size) * 16 was measured at 61 of 61 pairs and
is not reproduced on the correct blocks -- 0 of 13,042. The block set was the
error, not the arithmetic: the script that produced the 61 read packets[2]
where packets[1] was meant, so it measured 461 blocks instead of the 2,177 the
class tables define, and the formula agreed perfectly with a population that
was not the tie blocks. TieRecord::packet_bytes goes with it, and the
retraction is recorded in the format doc and in the struct.

That misread also explains why the earlier RAM verification saw different
numbers for the two inputs -- same lesson as rule 3, arriving through a wrong
subscript instead of a wrong span.
2026-10-13 04:22:38 +02:00
ReaperOfVeriod 4e90c282ad Make the tie record relocation a command, and verify it on two levels
The relocation rule from the previous commit was established by a script that
lived in a shell history, which means the next person would have had to
rediscover it. It is now `gc-trace verify-tie-records <unpacked-dir> <level>
<class> <lod> <src>`: resolve the class, parse the block, search the RAM image
for the loaded record table, report what it found, and exit 0 only when every
record's pointer has become table + word and every field byte is verbatim.

Swept over every (class, lod) slot of two levels, from PCSX2 savestates:

  level 0: 87 slots with a block, 87 verified, 0 mismatched, 61 absent
  level 1: 104 slots with a block, 104 verified, 0 mismatched, 78 absent

So the rule holds on every LOD block of two levels the game had loaded, which
is what makes the record layout a finding rather than a reading of one block.

Writing the command also caught an off-by-one in the parser it exercises:
TieBlockHeader::records started its slice after the 16-byte header, but the
header IS the first record -- the table runs from the block start to
data_offset, and a block with data_offset 0x50 holds five records, the first
being the header itself and its pointer naming where the payload begins. Found
because the tool's first run relocated nothing while the same check in a script
had passed; the unit test fixture was wrong in the same way and is fixed.

Two operational notes now in the format doc: the live emulator is not a stable
input (verifying level 1 over PINE matched 104 of 104 while the user was on
that planet, and the same command minutes later found no matching copy because
they had returned to level 0 -- a miss there is not a counterexample, so the
report says "no copy matched" rather than "failed"), and a class index past the
table count is an error rather than a mismatch, so a sweep should stop at the
level's class count rather than a round number.
2026-10-12 15:24:48 +02:00
ReaperOfVeriod e6eecb871f Decode the tie LOD block: it is a table of 16-byte records
The tie block's word at +0x00 was read as an element count, and the reading
failed in a way worth recording: the payload length divided by it is a whole
number on 0 of 922 blocks, at any record size and any header up to 512 bytes.
That is the correct measurement of the wrong hypothesis -- the word never
described the payload.

Range measured: every LOD block of level 1 whose word is a non-zero multiple
of 16, plus the two read by hand in full.

Method: locate each block's record table in PCSX2's RAM by its twelve field
bytes, which are the only distinctive 12-byte run in a block, then compare
every record against its disc copy. Each block has several copies in RAM, and
the one that matches is the loaded one -- checking the first hit is what made
the first attempt report 0 of 16.

Result: on 16 of 16 blocks, every record's leading word has become
table_address + word, and the other twelve bytes are byte-identical to the
disc. So a block is [16-byte header][record table][vertex payload], the records
are 16 bytes -- a leading u32 pointer plus twelve bytes of fields -- and the
header's word at +0x00 is the byte offset from the block start to the payload,
that is, the length of the table.

That is what the multiple-of-16 was: the table holds 16-byte records. It is
never zero because there is always at least the first record, and its 42
distinct values are table lengths. Each record's pointer is relative to the
table start, relocated at load to table + word.

TieBlockHeader::data_offset and TieBlockHeader::records carry it, bound by the
offset, with TieRecord for the twelve field bytes, and the disc test asserts
the table is bounded on every block. What the records' fields name and what the
payload holds are still open -- the payload is known to be variable-length,
float-shaped, not LZ77 and not VIF1.
2026-10-12 07:17:41 +02:00
ReaperOfVeriod c2af0528e3 Correct the chunk loader finding and decode a record field
The loader note said every record gains a 02 00 prefix in RAM. It does not:
the word is the record's own leading field, already on the disc at every
record. What the loader changes is the pointer -- it addresses the word, two
bytes earlier than the disc's table -- and that pointer shift is what made the
bytes look inserted.

Measured, in order:

- the two bytes before a payload are identical in core_data and in RAM, record
  for record: 02 00 on level 0's 90-record chunk at 0x100b302, 08 00 on the
  level's first chunk. Nothing is inserted;
- the RAM table's low 24 bits are the payload's minus 2, consistently, so the
  record is [leading u16][two index bytes][payload] and the loaded table
  addresses the leading word. That is also why the index bytes read as +2 from
  the pointer -- the same +2 the earlier RAM result quotes;
- the word is a field, not a pad: uniform across a chunk's records on 1,439 of
  1,509 chunks (None on the 70 that vary, reported rather than averaged),
  fixed within all 71 chunk types, and only ever 1, 2, 4, 6, 8 or 10 -- 554
  chunks carry 2, 520 carry 4.

Two readings of the value were measured and disproved, so they are recorded as
disproved rather than left as candidates: it is not an entry count (the payload
divided by it leaves 57 distinct fractional "sizes", and 162 chunks do not
divide at all), and it is not the number of distinct leading bytes (agrees on 0
of 1,506 chunks).

Chunk gains `prefix: Option<u16>`, and the disc test asserts the uniformity,
the per-type fixity and the value set. The scratch example that produced the
per-type class-ID table is dropped now that the same scan is a committed test,
which also lands here: moby class IDs sit in one body slot per chunk type, with
the slot varying by type and the distinct >= 4 filter load-bearing -- without it
the top slots are constants that read as 305x because any value below the moby
count counts as a hit.
2026-10-12 06:00:29 +02:00
ReaperOfVeriod 2c4bbdc600 Use PCSX2 RAM to recover the chunk loader's transformation
This is what RAM was needed for: nothing about the loader's transformation is
recoverable from the disc.

Anchor: a 32-byte probe from inside a chunk record in level 0's core_data, searched
in the level-0 savestate. It hits at 0x18538a8 and the record's first bytes at
0x185387e -- exactly chunk_base + record_offset, no rescaling. So the chunk payload
is copied VERBATIM; unlike collision and the shrub packet tables it is not expanded
or repacked.

Result 1: the offset table is rewritten to absolute EE addresses.
  disc: f000057c f00006b0 f00007e4 f0000914
  RAM:  0xf185387c 0xf18539b0 0xf1853ae4 0xf1853c14
The 0xf0000000|low24 tag becomes 0xf1850000|low24 -- tag replaced by the chunk's
load base, low 24 bits untouched. Consecutive deltas agree exactly (0x134, 0x134,
0x130), which is why the disc-side stride is approximate rather than constant.

Result 2: every record gains a constant 2-byte prefix 02 00, and the rewritten
table points at THAT rather than at the record data. Walking all 90 records from
the RAM dump: prefix 02 00 on 90 of 90; the disc's index bytes preserved unchanged
at +2/+3 on 90 of 90.

Result 3, the useful one: the byte at +2 takes exactly 0, 16, 32, ... 240 -- the
16 values index_rule predicts for step = 16, phase = 0. The mixed-radix record index
derived from the disc is corroborated on the running game's own copy of the chunk.
That is a disc-derived claim validated against the game, which is the strongest form
of check available here.

On disc a chunk is [16-byte header][10 zero bytes][tagged offset table][records].
In RAM it is [16-byte header][10 zero bytes][absolute-pointer table][records each
prefixed with 02 00]. The 02 00 is unexplained -- possibly a record type or an
alignment the loader needs -- but it is constant, so like the two index bytes it
carries no per-record information.
2026-10-12 04:32:07 +02:00
ReaperOfVeriod e2b12a2e52 Rule out a count field in chunk record bodies; correct a six-record impression
With the record framing settled (leading two bytes = record index), the body is
bytes 2..len. Four probes, mostly negative, recorded so the next attempt does not
repeat them.

No count field. For every chunk type, every u16 and u32 position in the first 64
body bytes, at element strides 1,2,3,4,6,8,12,16: does the stored value equal
(record_len - offset) / stride on 90%+ of records? No type produced a hit. The
record does not carry an element count near its head.

The body is dense, not float-dominated. For the largest type (2430 records,
326-byte bodies): f32 reads within +/-5000 of origin 74%, uniform-random control
49%, zero-byte fraction 13%. Above baseline but far short of the tie blocks
(53-64% against 16%, with 43% zero bytes).

There is an index-like region near the tail. In the largest type, 12 of 163 u16
slots have a maximum below 0x1000 -- too small for float or half-float, so those
look like indices or small counts -- clustered at slots 111-121 and 130-131.

A sample-size correction. Dumping six records of one chunk showed slots 112-119
zero on every record plus a long run of other constants, which reads as "the
record is mostly a per-chunk template". Measured across the 1,171 chunks with at
least 8 records: 15,422 of 164,390 u16 slots are constant across their chunk,
9.4%, and 0 of 1,171 chunks are entirely constant.

So 9.4%, and no chunk is a template. Six records looked like a majority because
that chunk's tail carries shared constants. Per type the figure ranges from 9.4%
overall to 55.6% on one type, so it is reported per type as well as pooled --
the pooled figure alone would hide that spread, which is the same trap rule 3a
describes.
2026-10-12 04:27:54 +02:00
ReaperOfVeriod 28680b4be3 Show the first two chunk-record bytes encode the record's own index
Continuing from the phase counter. Within a chunk:

  i    :  0  1  2  3  4  5  6  7  8  9 10 11 ... 15 16 17 ...
  byte0:  0 16 32 48 64 80 96 112 128 144 160 176 ... 240  0 16 32
  byte1:  0  0  0  0  0  0  0  0  0  0  0  0 ...   0  1  1  1

byte0 is step*(j mod g) and byte1 is (j / g), where g = 256/step and
j = index + phase. The pair is the record's index in mixed radix: byte0 carries
the low digits scaled by step, byte1 the quotient.

Measured over all 27 levels: 1,235 of 1,509 chunks fit this on both bytes. Byte 0
alone fits 1,262, so the 27-chunk difference are chunks whose first byte follows
the rule and whose second does not. On 1,235 chunks a record knows its own
position and the two leading bytes hold no per-record information.

ResolvedChunk::index_rule returns the (step, phase) only when the whole chunk fits
on BOTH bytes, so the 274 that do not stay distinguishable and nothing skips a
leading byte on a rule that does not apply to it.

A number that moved when the implementation changed: a first probe reported
1,181; the committed method reports 1,235. The method is right. The probe
accumulated ok0/ok1 flags across candidate phases with an early-exit that could
skip a phase which would have matched both bytes, and it also disagreed with
bucket_rule on the byte-0 count (1,208 against 1,262), which is what gave it away.

Recorded because both numbers came from the same run and only the discrepancy
exposed the probe as wrong. The lesson is narrow: when a one-off probe and a
committed helper disagree, check the probe first.
2026-10-12 04:25:44 +02:00
ReaperOfVeriod 79914c420e Show the chunk record's leading byte is a per-chunk phase counter; every chunk
has a uniform record length

Follows the alignment result and settles what the leading byte is.

Within one chunk the leading byte steps by a constant and wraps:

  offsets: 1420 1748 2072 2392 2716 3044 3372 3700 4028 4352 ...
  byte0:      0  16   32   48   64   80   96  112  128  144 ...

Sweeping every (step, phase) over all 27 levels:

  fully explained: 1,262 of 1,509 chunks
  by step: 8 -> 51 chunks, 16 -> 554, 32 -> 520, 64 -> 137
  unexplained:  247 chunks across 11 chunk types

So on 1,262 chunks the leading byte carries no per-record information: it is a
deterministic function of the record's index and the chunk's phase, which is a
position marker rather than data. That is why the earlier "16 discrete values,
uniform counts" pattern appeared at all.

ResolvedChunk::bucket_rule returns the (step, phase) when the whole chunk fits and
None otherwise, so the 247 unexplained chunks stay distinguishable and nothing
skips a leading byte on the strength of a rule that does not apply to it.

Every chunk has a uniform record length: 1,509 of 1,509. Length varies BETWEEN
chunks of a type -- one type runs 76 to 104 bytes -- but never within one. This
withdraws the previous commit's "variable-length type", which came from pooling
minlen/maxlen across all of a type's records at once. That is the pooled-claim
error rule 3a exists to prevent, and it is the third time this session a pooled
measurement produced a claim that partitioning reversed. The type is not
variable-length; the chunk is fixed-length and different chunks differ.
2026-10-12 04:21:32 +02:00
ReaperOfVeriod a9bf74fc10 Characterise all 77 chunk types: record byte 0 is a bucket index, not data
Characterised every chunk type -- 77 of them, 1,509 chunks, 34,491 records, all 27
levels.

Byte 0 of every record is a small aligned index, not record content. It takes 16
discrete values per chunk type (0x00, 0x10, ... 0xf0) with near-uniform counts
WITHIN each type. Uniform weighting is the tell: a running byte offset would
weight the buckets unevenly, because records differ in size.

  type_id      recs  len  byte0
  0x007d7ec438 2430  328  0x00:162 0x10:162 0x20:162 ... 0xf0:135
  0x00429ec43d 2079  368  0x00:162 0x10:135 0x20:135 ... 0xf0:108
  0x00d76cc453  810  108  0x00:27  0x08:27  0x10:27  ... 0xe8:27   <- steps by 8

The alignment step is per type, not global. The leading byte is a multiple of 8 on
34,432 of 34,491 records but a multiple of 16 on only 33,349 -- the 1,142-record
difference falls in seven types, four of which step by 8. 59 records in three types
break even the 8-byte rule, and that is where the variable-length type lives:
0x00f2ef4275, records 76 to 104 bytes, no single stride to align to.

What the bucket indexes is NOT named. It is reported as CHUNK_RECORD_ALIGNMENT and
record_is_aligned rather than as a parsed field, because naming it would be a guess
while the invariant is solid. The test pins the exact exception set -- 59 records
across 3 types -- so a change there fails loudly.

A measurement I initially got wrong: the first version of the constant claimed a
16-byte multiple on 34,432 records. 34,432 is the multiple-of-8 count; the
multiple-of-16 count is 33,349. Right number, wrong label, and the test caught it.
That is the second time this session a correct number carried a wrong label, after
the tie four-value retraction. Both times the assertion did, which is the argument
for putting these numbers in tests rather than only in prose.

Still open: what the bucket indexes, and the remaining record fields.
2026-10-12 04:18:55 +02:00
ReaperOfVeriod 291153a0d9 Find the tie LOD block terminator; packets == 0 means no block at that LOD
parse_tie_block walks to a 0xf6 terminator that every real block carries, on
2177 of 2177 across all 27 levels, followed by zero padding. This is what makes
the blocks parseable: packets[1] bounds one on only 461 of 1864 classes, so the
terminator is the only general way to find one.

The terminator was worth a control, because 0xf6 could have been byte-frequency
coincidence. It occurs in payloads at 0.83x the uniform rate -- slightly LESS
often than chance -- while the last non-zero byte is 0xf6 on 909 of 922 blocks in
the first subset, against 17 of 922 for the same blocks cut at a random point. So
it is a terminator, not a common byte.

packets == 0 means the class has NO block at that LOD: of 3,728 (class, LOD)
slots, 2177 point at a block and 1551 are exactly zero.

Corrected header, over all 2177:

  +0x00  count    multiple of 16 on all; 42 distinct; never zero
  +0x02           zero on all
  +0x04           exactly four values: 0x501 (1069), 0xa02 (720), 0xf03 (350),
                  0x1404 (38)
  +0x06           16 distinct, never zero
  +0x08           180 distinct, never zero
  +0x0a           61 distinct, never zero
  +0x0c           125 distinct, never zero, clustered 0x043f..0x0443
  +0x0e           11 distinct, zero on 2162 -- the only non-universal field

+0x0c clusters around 1087-1091, close enough to a block length to be worth
stating, but it is NOT the length: the difference against the true payload length
scatters from -12 to -172.

A correction to the previous commit. It reported 3,728 blocks all terminating and
packed_texture taking 842 distinct values, and on that basis retracted the
four-value claim. Both numbers were wrong the same way: the scan did not skip
packets == 0, so it computed class_base + 0 and walked forward from the CLASS
BODY, finding a terminator there by accident. That contaminated 1551 of the 3728.

With the zeros excluded the slot takes exactly four values on all 2177 blocks.
The four-value claim was right; my retraction of it was wrong; and the retraction
was made on a number from the same contaminated set. A retraction is not
automatically better than the claim it retracts -- it needs its own bounds.

Still open: the payload's records. Established this round: blocks are
self-delimiting, absent LODs are marked by a zero packets word, and the header is
fully characterised. Not established: any record boundary inside the payload,
which stays variable-length.
2026-10-12 04:13:52 +02:00
ReaperOfVeriod 02342de31b Decode the tie LOD block header; establish the payload is variable-length
TieBlockHeader parses the block's own 16 bytes, measured over the 922 blocks
where "the next packets offset ends this block" holds, across all 27 levels. A
real-disc test asserts every figure.

  +0x00  count        multiple of 16 on 922/922; 16..2096; 72 distinct disc-wide
  +0x02               zero on 922/922
  +0x04               ONE OF FOUR VALUES DISC-WIDE: 0x501, 0xa02, 0xf03, 0x1404
  +0x06               15 distinct, 0x0206..0x0512
  +0x08               135 distinct, 0x0b05..0x4ee4
  +0x0a               45 distinct
  +0x0c               94 distinct, 0x010e..0x0553
  +0x0e               zero on 914/922

+0x04 is packed rather than a magnitude: four values across the whole disc with
the two bytes moving independently, low 1-4 and high 5/10/15/20 in lockstep. That
it is a texture index paired with a scale is a guess; that it is two fields is
not.

The count is not a record count -- block_size minus the header is divisible by it
on 0 of 922, for any record size and any header up to 512 bytes.

On the payload, a control was needed and changed the answer. Reading blocks as
3 x f32 triples, 53-64% of reads land within +/-5000 of origin, against 16% for
a uniform-random control of identical length. So the content is float-shaped and
genuinely not random.

But the rate is flat across every start offset and every stride tried, 53-65%
throughout. A statistic that cannot separate the right stride from the wrong one
cannot identify a stride -- rule 4's exact case. Flatness plus a count that does
not tile means the records are variable length.

That killed two readings. A VIF1 command walk consumes 0 of 88 blocks exactly,
so it is not a VIF1 CPU-format stream. wad::decompress rejects the block at every
start offset, so it is not WAD LZ77.

Scanning every (start, stride) pair from 0x10 to 0x7f for byte positions with a
small distinct-value vocabulary finds none: every position takes all 256 values
at every alignment. For float payload that is expected, so the scan found nothing
rather than confirming anything -- worth saying, because "no fields found" here is
uninformative, not evidence against fields.

Still open: what the payload holds. Established: variable-length, float-shaped,
non-random, not LZ77, not VIF1. Not established: any record boundary.
2026-10-12 04:09:08 +02:00
ReaperOfVeriod 881fd48e9a Withdraw the 16-byte tie record claim: block size is a multiple of 16, not records
The previous commit inferred a 16-byte record from "922 of 922 consecutive block
sizes are a multiple of 16". That inference does not hold, and the claim is
withdrawn rather than softened.

What survives:

- Every one of those 922 blocks opens with a u32 that is positive and a multiple
  of 4, on 922 of 922, ranging 48 to 1744 with 17 distinct values across level
  0's LOD0 blocks. The block opens with a count, not a size and not a pointer.
- Reading that u32 as a byte size and chaining to the next block lands exactly on
  the next block start on 0 of 1383 blocks. Not a size chain.
- Assuming [header][count][count uniform records] and solving for the record size
  gives no answer: no record size tiles the block for any header size up to 512
  bytes. Not 16, not 12, not 8.
- Reading the block as uniform 16-byte units gives fields spanning their entire
  legal range -- u32@0 from -2139029504 to 2139029587, u16@8 and u16@10 both
  0..65535, and 8969 distinct values in i16@0 on level 0. Uniform units would not
  produce that.

So the 16 is a property of the block size, not of a record. The earlier commit
also claimed a record size "on the classes where it can be measured"; those
classes are still correctly identified, but what they measure is the block size.

The strict-increase disproof from two commits ago stands untouched and is what
should have prevented this: the word at +0 was already known not to behave, so
treating 16-byte units as records contradicted a measurement sitting in the same
file. Divisibility is arithmetic; it is not evidence of a record boundary.

Roadmap updated to say block sizes are established and the record layout is not,
with the three measurements that rule the 16-byte reading out.
2026-10-12 03:56:41 +02:00
ReaperOfVeriod 855e640f39 Establish the 16-byte tie record on measurable classes; packets[0] is a constant
The previous commit reported "3580 non-empty LOD blocks compute as divisible by
16" and then said the figure should not be trusted, because it came from the
"next packets offset ends this block" rule. That rule is unsound: packets is
strictly increasing on only 461 of 1864 classes. On the other 1403 the two
offsets are unordered and cannot both be block starts into one ascending region.

Restricting to the 461 classes where the rule does hold, 922 of 922 consecutive
block sizes are a multiple of 16. That is the evidence for a 16-byte record, and
it is now scoped to the subset where it can be checked rather than claimed over
all 1864. The other 1403 are unmeasured, not measured-and-different.

packets[0] is a constant, not an offset: it reads 256 on 1864 of 1864 classes,
while packets[1] and packets[2] vary widely and land at different addresses in
core_data on every class. Read as a block start, 256 points just past the
0x48-byte header -- which is how it was being used, and exactly how a field gets
mistaken for a pointer. It is now TIE_PACKETS0 and nothing depends on it.

Still not decoded: what the 16 bytes hold. i16[0] at +4 takes 8969 distinct
values on level 0 and is not the two-valued field a 12-unit sample suggested; the
word at +0 is not a run offset.

The test pins all four numbers -- packets[0] constant, the 922/922 divisibility,
the 461/1864 ordering, and the 0/1864 monotonic disproof -- and asserts that the
ordering is *not* universal, so a future change there fails loudly instead of
quietly invalidating the record size.
2026-10-12 03:50:42 +02:00
ReaperOfVeriod 60e3f129d8 Resolve both tie indirections on all 1,864 classes, and disprove a sample artifact
resolve_tie_class walks the chain, which is two steps and both are easy to get
wrong:

  1. The tie class table lives in core_index.bin, not core_data.bin. On level 0
     the tables chain as moby at 192, 238 x 32 bytes later tie at 7808, 74 x 32
     later shrub at 10176, which closes. Reading the same table out of
     core_data.bin produces plausible garbage rather than an error.
  2. ClassEntry::offset_in_asset_wad addresses core_data.bin, despite the field
     name and despite core_data.wad sitting alongside it.

Verified: all 1864 tie classes resolve, o_class agreeing on every one, and all
5592 LOD block offsets land inside core_data.

Disproved, and this is the part worth reading: the word at +0 of a 16-byte unit
inside an LOD block. On the first twelve units of level 0's class 0 it rises
strictly -- 288, 1520, 2848, 3968, 5088, 6096, 7312, 8528, 9792, 10976, 12016,
13072 -- which reads exactly like a linked list, and the accompanying i16s looked
quantised, two distinct values in i16[0] and three in i16[1].

Across the population it is strictly increasing on 0 of 1864 blocks, and i16[0]
takes 8969 distinct values on level 0 alone rather than two. The sample was
twelve units out of 1,724,334. Both the monotonic reading and the quantisation
were artifacts of that sample.

That is the pooled-claim error AGENTS.md rule 3a exists to prevent, committed
almost verbatim: the first twelve units looked clean, and I was one step from
writing "u32[0] is a linked list of runs" into a format spec. The test now
asserts the disproof, so it cannot creep back.

Not established: the block's record size. 3580 non-empty LOD blocks compute as
divisible by 16, but some packets offsets run backwards, so the "next offset
delimits this block" end is wrong for them and that figure should not be trusted.
The tie layout stays partial: indirections and header done, records not.
2026-10-12 03:47:49 +02:00
ReaperOfVeriod ccea53807a Resolve the shrub region bound: the chunk container closes it
The shrub region's end was the last unlocated bound in core_data. It was not
in the core index, and unlike moby->tie it had no successor boundary to chain
against, so the last shrub class in every level ran out to the lump's end and
the aggregates over it (the "3.5M records / 28MB of shrub body") were
artifacts of that oversized span.

Range measured: for each level, from the last shrub class's geometry offset to
the constant trailer. Nothing outside that range is read.

Method: scan forward from the last shrub class for the pair (a) the constant
two bytes f6 7c sitting immediately in front of (b) a chunk signature -- a
16-byte header ending 0xff, ten zero bytes, then four or more 0xf0-tagged
offsets. The two halves are each weak alone, and the halves are measured
separately: the marker pair occurs 25 to 103 times in a 5 to 17 MB lump, so
matching it alone picks dozens of wrong offsets; the chunk shape matches
random data often enough to need four tagged offsets before it is believed.

Result: on all 27 levels the marker sits exactly two bytes before the first
chunk header, and the two parts of the signature are never found apart. No
chunk signature occurs inside any last shrub body, on any level. The three
levels without shrub (10, 25, 26) carry the same marker before the container
that closes tie instead, which is what makes the locator a property of the
region rather than a shrub rule.

Consequences:

- All 358 shrub class bodies are bounded and are whole 8-byte records. That
  was not assertable while the last class in each level ran to the lump's end,
  and it is the check the boundary earns its keep for.
- 9,600 records in total, against the 8,938 previously measurable in
  successor-bounded spans; 662 of them sit in the 24 last-class bodies. The
  shrub region totals 2,033,776 bytes across the 24 levels that have one.
- The chunk container is 25,775,850 bytes across 27 levels (1,236 chunks,
  22,562 records). The container measurements were previously taken over "the
  900,000 bytes before the trailer", a window that did not align to its real
  extent: on level 0 it is 690,302 bytes, and 27 of 1,123 chunks were
  truncated at that invented edge. chunk_containers_resolve_on_every_level now
  reads the region from core_layout instead of measuring backwards from the
  end of the lump.

core_layout takes core_data now: the container's start is the one bound that
cannot be derived from the index. It gains the chunks region, so every byte of
level-specific core_data belongs to a named region. Where the signature is not
found the end stays None -- the region is left visible rather than clamped to
the tail.

Still open: the marker's meaning (constant across 27 levels, at exactly this
position, unnamed) and the shrub body payload's encoding.
2026-10-11 06:20:48 +02:00
ReaperOfVeriod 4d1e277a15 Exclude f32 and u16 readings of the shrub body record
Range measured: level 0 shrub bodies, 164 records of 8 bytes, following the
previous commit.

The six varying bytes are not an f32 pair:

  f32 @0  158/164 finite, range up to ~1e35. Not positions or normals. The 6
          non-finite values (3.7%) are far above the 0.05% random bytes would
          give, so the field is structured but is not an f32.
  f32 @4  164/164 finite, range 0 to 9.18e-5. Looks promising until you notice
          bytes 6 and 7 are always zero, which pins it to a coarse grid and
          explains the tiny range. Not independent evidence.
  u16 @0  85 distinct, range 0..65534
  u16 @2  90 distinct, range 0..65534
  u16 @4  80 distinct, range 0..65512

The u16 fields are not small counts or indices; they span essentially the whole
range.

Excluded: two packed u32, an f32 pair, and narrow u16 fields. The six bytes
remain uninterpreted.

Worth noting the f32 @4 case as a trap: a plausible-looking finite non-negative
float range that was entirely an artifact of the two constant zero bytes
pinning it. Checking the constant bytes before believing a float reading is now
the order to work in.
2026-10-11 04:45:35 +02:00
ReaperOfVeriod 7c0df0f893 Characterise shrub body records on level 0 as a per-level partition
Range measured: level 0, shrub class table, 7 entries at core index offset
10176. Six classes are bounded by a successor; the seventh runs to the end of
core_data and is excluded because its span contains the whole post-shrub
region.

Method: bound each span by the next class geometry offset, take the body from
data_offset to the span end, split into 8-byte records.

Result: 164 records across 6 bounded bodies, lengths 192 twice, 208 twice, 224,
240.

Per byte over those 164 records:

  byte 0: 74 distinct, 60 zero      byte 4: 72 distinct, 35 zero
  byte 1: 58 distinct, 65 zero      byte 5: 59 distinct, 38 zero
  byte 2: 76 distinct, 43 zero      byte 6:  1 distinct, 164 zero
  byte 3: 62 distinct, 48 zero      byte 7:  1 distinct, 164 zero

Bytes 6 and 7 are zero in every record; bytes 0 to 5 take 58 to 76 distinct
values with only 21 to 39% zero, so uniformly varied rather than sparse.

Read as u32 the fields are not plausible: u32[0] spans 0 to 4294871316 and no
field is monotone across the body. The six bytes are not two packed 32-bit
values.

This settles the six-varying-plus-two-constant shape on level 0 as a per-level
partition, rather than only in aggregate over the disc's 8938 records, which
is what rule 3a asks for. Two trailing zero bytes are padding or a flag pair.

Unresolved: whether the six bytes are six u8, an f32 plus two u16, or
something else. The u32 reading is excluded but no positive reading is
established.

Process note: the first attempt used ClassEntry and a hand-computed o_class
instead of ShrubClassEntry, and every ShrubHeader::region call failed. The
parser was fine; the caller was not.
2026-10-11 04:42:53 +02:00
ReaperOfVeriod efe4c06be6 Show the chunk header does not encode the record stride
All 90 two-byte windows of header[0..14] were tested: none maps to a unique
stride across every group. There is no simple two-byte field to read the
record size from.

The stride must therefore come from the offset table, by differencing the
first two tagged offsets, which is what the parser already does.

This validates that choice as more than convenience. Given three chunks sharing
one header with three different strides, a dispatch that assumed "header
implies size" would be wrong for the whole type-ID coverage on at least one
layout. Reading the stride from the offsets is the only interpretation
consistent with every measurement so far.

Negative result recorded deliberately: it rules out a whole family of
hypotheses (stride encoded in the header) rather than narrowing one.
2026-10-11 04:39:46 +02:00
ReaperOfVeriod 1d84562e88 Resolve the colliding header: three types share 16 bytes
Header 7542eff271b522a79e45348cca4509ff was reported with three strides, 76, 92
and 104. It is not one type with a variable stride, and not a misparse.

It appears three times per level, always with record count 9, always in the
same order at the same offsets within the region:

  offset   stride  count  first  last
  863842      92      9     928  1640
  867554      76      9     912  1520
  872866     104      9    1000  1832

The offsets are 3712 apart each time and the strides differ, so these are three
separate chunks sharing 16 header bytes.

Consequence for the type ID: 64 distinct headers for 66 distinct record
layouts, not 64 for 64. The header is nearly a type ID but does not uniquely
determine the layout. Not excluded: a distinguishing byte sits outside the 16
captured, or the layout varies within a type.

This does not change "63 of 64 headers imply one stride", which remains true,
but the mapping is not a bijection and should not be used as one. Worth
checking before the parser is extended to dispatch on header alone.
2026-10-11 04:37:25 +02:00
ReaperOfVeriod b13901a7e7 Correct the moby class array offset: it is per chunk type
The previous commit reported moby class IDs at u16 slots 129 through 140. That
was a pooling artifact. Repeating the scan per chunk type shows every type
places its class array at a different offset:

  aac4  129-134     82c4  104-109     4ec4  106-111
  b8c4  120-125     9943   82-87      79c4  111-115
  3245   24-29      and so on across all 26 types

Slots 129-140 were simply the type with the largest stride dominating a scan
that pooled every type together. The previous commit flagged this as
unverified; the check confirmed the flag.

What survives is stronger than what it replaced. In every one of the 26 types
the top hits form a contiguous run of consecutive u16 slots. The real
invariant is therefore per-type: each chunk type has its own class array, at
its own offset, of its own length. That fits the header-implies-one-stride
result -- each type is a distinct record layout -- but the class array is a
per-type field, not a common one at a fixed offset.

The moby reference itself is unaffected. The 38.6x excess was measured across
all records and does not depend on which slots were counted.
2026-10-11 04:31:15 +02:00
ReaperOfVeriod ddfbc6eb33 Find moby class references in chunk records at 38x chance
Range measured: level 0, the 900000 bytes before the trailer; 28 chunks, 475
records, 67780 u16 slots. Reference set: the 238 distinct o_class values from
level 0's moby class table, read through LevelCoreHeader.

Method: scan every u16 in every record, count membership in the moby class set,
and compare against the chance rate of 238/65536 per slot.

Result: 9493 hits against 246.1 expected, a 38.6x excess.

The hits are concentrated, not spread. Slots 129 through 140 -- twelve
consecutive u16, bytes 258 to 280 of the record -- carry 105 to 188 hits each.
Nothing else comes close except a separate cluster at slot 38. Every chunk
contributes in proportion to its record count.

A contiguous twelve-value array that is mostly moby class identifiers is what a
model or instance definition listing referenced mobies would look like.

Enrichment is not identification. A 38x excess over chance is strong evidence
these are moby class IDs rather than incidental small integers, but it does not
establish that the chunk is moby_anim: an instance table, a model list and an
animation table would all reference class IDs. The format and the meaning of
slots 129-140 are both still unnamed.

Also unverified: whether the twelve slots are always the same fields. The
offsets come from the largest records, and this scan pooled all chunk types
together, so a type with a different stride could place them elsewhere.

Process note: the first version of this test extracted the moby class table by
hand and got 67 entries instead of 238, which made every chunk look like a hit
and would have supported any conclusion. Reading it through LevelCoreHeader was
what made the measurement meaningful.
2026-10-11 04:29:44 +02:00
ReaperOfVeriod 379c12f8ec Identify the chunk header as a type ID that determines the record stride
Range measured: all 27 levels, 1123 chunks.

Method: group chunks by their 16-byte header; for each distinct header,
collect the set of strides seen across every level it appears on.

Result: only 64 distinct headers across 1123 chunks. The header is a type
identifier, not a per-chunk descriptor -- the same 16 bytes recur on every
level. And 63 of those 64 imply exactly one stride, so header to record layout
is very nearly a function.

The exception is header 7542eff271b522a79e45348cca4509ff, seen with strides 76,
92 and 104. Either three types share a header or one is a misparse.

This also settles the previous entry's open question about D/E/F being
incidental. Grouping by header bytes 9 and 13:

  DE   26 chunks,   2 strides (184, 344)
  EE 1050 chunks,  46 strides
  EF   34 chunks,   2 strides (268, 640)
  FF   13 chunks,   1 stride  (520)

Rare tags are tightly coupled to their layouts -- FF is a single stride across
all 13 chunks -- while EE is heterogeneous. That is what a type tag looks
like.

Consequence: a chunk can be dispatched on its header to a record layout, and
63 of 64 headers give that layout's size without inspecting a record. First
point in this container where a field does real work rather than correlating.

Still open: what the 64 types mean, which record fields are
index/length/offset, and whether any type is moby_anim.
2026-10-11 04:28:12 +02:00
ReaperOfVeriod 10188416e2 Decode the chunk count field: header byte 14 is the record count
Range measured: all 27 levels, 1123 chunks, over the 900000 bytes before the
constant trailer.

Method: compare header byte 14 against the number of offsets actually resolved
for that chunk.

Result: byte 14 equals the record count on 1123 of 1123 chunks, zero
mismatches. This is the first genuinely decoded field in this container rather
than a measured correlation.

The parser now truncates the offset table to header byte 14, so a stray
tagged word in a chunk's record data cannot silently extend the table and put
the count at odds with its own header.

Two more header bytes are near-constant and read as ASCII: byte 13 takes only
0x45 (1076 chunks) and 0x46 (47); byte 9 takes 0x44 (26), 0x45 (1084) and
0x46 (13). Those are D, E and F -- a three-letter tag at two byte positions
with different distributions. Recorded as suggestive, not established: the
other 14 header bytes are high-entropy and read as ASCII at no shift tried, so
the D/E/F may be incidental. Worth testing against a known type table.

Still open: the 14 opaque header bytes, whether bytes 9 and 13 are type tags,
which record fields are index/length/offset, and whether a chunk is moby_anim.
2026-10-11 04:26:44 +02:00
ReaperOfVeriod 8b0a2545a3 Confirm chunk offsets address real records on all 27 levels
Range measured: each level's post-shrub region, 900000 bytes before the
constant trailer.

Method: per chunk, read tagged offsets as 0xF0000000 | low24, take stride as
the first delta, load one record per offset at chunk_start + low, and count
byte positions constant across that chunk's records.

Result: 1096 chunks and roughly 13000 records resolve on all 27 levels. Every
level shows 17-21% of byte positions constant across its own records. Level 0:
27 chunks, 466 records, 20.8% constant. The stride-368 chunk (77 records) has
26 constant positions of 368; the stride-340 chunk has 25 of 340. Constant
positions cluster at the record end -- 267-276 for stride 368, 258-268 for
stride 340 -- so a footer, not scattered padding.

The tag is uniform: every tagged offset has top byte 0xF0 on all levels.

This settles the previous entry's framing. Resolving at chunk_start + low
yields records with ~20% constant positions, which does not happen by chance,
so the offsets genuinely address records and those records share a layout.

Still not decoded: the 16-byte header, the 0xF0 tag, which fields are
index/length/offset, and whether a chunk is moby_anim. A parser can extract
chunks and records bounds-checked today; naming the fields needs a domain
argument this project has not found.
2026-10-11 04:21:49 +02:00
ReaperOfVeriod 1d6cddf3bb Identify the post-shrub region as a chunk container
Range measured: level 0 post-shrub, 16047040..16737344, 690304 bytes.

Method: scan for a 16-byte header ending 0xff, then 10 zero bytes, then a run
of u32 whose top nibble is 0xF.

Result: 28 chunk headers on level 0, present on all 27 levels. Each chunk is

  +0x00  16 bytes, last byte 0xff   (opaque, differs per chunk)
  +0x10  10 zero bytes
  +0x1a  N tagged offsets, top nibble 0xF

Within a chunk the offsets step by a chunk-specific stride. On level 0:
148, 340, 368, 288, 320, 236, 272, 384, 116, 160, 250, 580, 576. Near-constant
within a chunk, different between chunks.

This resolves two earlier results, both of which were half-right.

The 368-byte period is one chunk's stride, not a region property. That is why
the region-wide autocorrelation is flat: ~15 strides averaging to noise. The
15360-byte window where 368 scored +0.315 is the third chunk at region+30066,
whose stride is 368.

The "ten-entry table" earlier disconfirmed is real. Chunks carry 5 to 30 tagged
offsets, 10 most common on level 0. It was disconfirmed because I tested for a
fixed 148 stride across a whole region; the table exists, but 148 is one
chunk's stride among many.

Both errors were the same one: reporting a local measurement as a regional
property. Twice now. The control methodology caught neither, because a matched
control bounds significance but says nothing about scope.

Not decoded: the 16-byte header, the 0xF tag, where chunk data lives, and what
any chunk means. Framing established, contents not.
2026-10-11 04:20:10 +02:00
ReaperOfVeriod c60340b1de Correct the post-shrub 368 stride: real, but in one 15 KB window
The claim that the post-shrub region has a 368-byte period throughout is
wrong. Re-measured over the whole region the curve is flat and near noise:

  stride   8  +0.027
  stride  12  +0.023
  stride 148  +0.009
  stride 340  +0.018
  stride 368  +0.018
  stride 632  +0.020

Nothing peaks at 368, and the 0.1110 recorded earlier does not reproduce under
any sampling convention tried.

Where the structure is: scanning 8 KB windows at 1 KB steps for a 368-excess
above 0.15 finds one substantial region, region+41984..+57344, 15360 bytes,
plus a 4 KB patch at region+650240.

Within that window 368 is unambiguous -- +0.315 against +0.284 at 736, with
364, 372 and 732 all at noise. 41 whole records, mean 16 distinct values per
position, and nine positions exactly constant and zero: 329-336 and 340.
Twenty-five positions have 3 or fewer distinct values, clustered in 325-349,
which is a tail rather than scattered padding.

Corrected statement: a 15360-byte window of the post-shrub region is a table
of 41 records at stride 368, each with a roughly 43-byte tail whose fields are
mostly zero. The surrounding 675 KB is not that table.

What went wrong before: the original measurement was taken over a window where
368 held, then reported as a property of the region. That window holds 41 of
1841 records, and averaging it over the region flattens the peak to the noise
floor. The re-verification checked the stride but not whether it applied
uniformly, so it confirmed the number without testing the claim attached to it.
2026-10-11 04:13:55 +02:00
ReaperOfVeriod 7992e9d8f3 Note that the SBlk period is not a record boundary
Range measured: level 0 SBlk, 24..19508, read at 4-byte alignment.

The most common u32 values are 0x0 (2261), 0x1 (369), 0x7f (168),
0x5a000000 (125), 0x1eff7f7f (125), 0x1dfcc (124), 0x19fc0 (93),
0x7c34240d (91), 0x7c3821c8 (91).

That does not look like a table of records with typed fields. A 40-byte layout
of index, length and offset would not produce 0x1eff7f7f and 0x7c34240d a
hundred times each, nor would those values survive a byte shift.

So either the 40-byte period is not a record boundary, or the region is an
encoded stream that happens to have a 40-byte statistical period. The 0x7f
values point at the latter: in a varint encoding 0x7f is the largest value
representable in seven bits, so it appears as a frequent saturated byte --
consistent with a coded stream, inconsistent with fixed-layout records.

Left open. The 40-byte period is solid -- 27 of 27 levels, sharply isolated,
multiples confirming -- and so is the alignment with four constant zero
positions. But those two are in tension in a way nothing else in this project
has been: a confirmed period that is not a record boundary. Someone attacking
this format should not assume the record framing that the autocorrelation
suggests.
2026-10-11 04:09:02 +02:00
ReaperOfVeriod e9c927994c Establish the SBlk 40-byte record layout
Range measured: level 0 SBlk, 24..19508, aligned at +0 -- 487 whole records.

Method: for each of 40 candidate alignments, count byte positions that are
near-constant across records, and keep the best. Alignment +0 wins with 10.

Four positions are exactly constant and all zero: 7, 11, 31 and 35.
Distinct values per position run from 1 to 155, with position 29 the most
variable by a wide margin -- 155 against 21 to 48 elsewhere -- and positions
2, 10, 27, 34 and 39 low at 2 to 8.

The first record carries the SBlk magic and the DAW tag rather than descriptor
data, so it is a header rather than an entry. That matters before reading any
field at a fixed offset.

Not decoded: the layout is established and the constant positions are known,
but the fields are not named and which of the 40 bytes are an index, a length
and an offset is still open.
2026-10-11 04:06:51 +02:00
ReaperOfVeriod 0f1a7f27c7 Resolve the SBlk discrepancy: 40 is the record, markers are nested
A fine scan settles it in favour of 40:

  stride 40  +0.454
  stride 39  +0.266
  stride 41  +0.215
  stride 38  +0.214

The peak is sharp and isolated, nearly twice its immediate neighbours, and
the multiples confirm it: 80 scores +0.376, 120 +0.394, 160 +0.340, while the
multiples of 12 (24, 48, 96, 144) all sit at 0.21 to 0.24.

So the record is 40 bytes and the 0x7f/0x47f markers at 12-byte spacing are a
nested structure inside those records, not the record boundary. Their spacing
is irregular -- 12, 24 and larger -- which is what one would expect of a field
present in some records and absent in others.

The previous commit's "they disagree" framing was itself wrong in one
respect: the marker spacing never claimed to be the record size. Read as a
claim about markers inside records rather than about the record itself, the
two measurements were never in conflict, and I had set up a false dichotomy
between them.
2026-10-11 04:06:42 +02:00
ReaperOfVeriod 5df3914160 Record an unresolved discrepancy in the SBlk record size
Range measured: level 0 SBlk, 24..19508, 19484 bytes.

The 0x7f/0x47f marker words sit at 12-byte spacing on 144 of roughly 180
occurrences, with 24-byte and larger gaps accounting for the rest. So there is
a 12-byte structure.

That contradicts the autocorrelation. If records were 12 bytes, stride 12
should peak, and it does not:

  stride 12   +0.237
  stride 20   +0.175
  stride 24   +0.237
  stride 40   +0.454
  stride 48   +0.239

40 is not a multiple of 12, so the measurements disagree rather than one
explaining the other. Recorded as open rather than resolved in favour of
either: the marker spacing says 12, the period says 40, and 40 being twice as
strong is the more surprising of the two.

Worth resolving before attempting a record layout, because a layout built on
the wrong one will parse plausibly and be wrong.
2026-10-11 04:06:31 +02:00
ReaperOfVeriod d3373042b9 Confirm moby's 16-byte vertex by two independent methods
The part table established vertex_offset + 16 * vertex_count == next_part on
43467 of 43467 parts, which fixes the vertex stride at 16. Autocorrelation
over the spans now reaches the same number without using that result.

Range measured: moby class spans of 16 KB or more on level 0 -- 60 spans,
bounded by consecutive class offsets, over their first 32768 bytes.

Method: peak-stride autocorrelation against a matched control, strides 4 to
64.

  stride 16   43 spans
  stride  8    8
  stride 48    4
  stride 32    3
  stride 24    2

16 dominates and it is the same 16 the part table produces from a completely
different argument -- a tiling identity over part records rather than a
periodicity measurement over span contents.

Two unrelated methods agreeing is worth more than either alone. The
autocorrelation would be vulnerable to the exact artifacts this project has
already been bitten by: a dominant byte value inflates it, and a period that
happens to divide the measurement window looks identical to a real one.
Neither applies here, and a tiling identity cannot be inflated at all.

This is the strongest form of evidence produced so far in this project -- a
structural invariant confirmed by two methods that do not share a failure
mode.
2026-10-11 04:04:16 +02:00
ReaperOfVeriod 332dc63600 Measure tie class spans: 16-byte unit, 48-byte period, dominant not universal
Range measured: every tie class span of 16 KB or more on levels 0, 3, 6 and
every third level after -- 386 spans, bounded by consecutive class offsets.

Method: peak-stride autocorrelation with a matched control over the first
32768 bytes of each span.

  stride 48   285 spans
  stride 16    58
  stride 64    29
  stride 96    11
  stride 24     2
  stride 32     1

48 bytes dominates but is not universal: 58 spans peak at 16 against 285 at
48, so this is a majority rather than a constant. Every observed peak is a
multiple of 16, suggesting a 16-byte base unit with 48 arising from how those
units group rather than 48 being the record.

This is weaker than the sound bank's 40-byte result and is recorded at the
confidence it deserves. That one was sharply isolated -- 0.454 excess at 40
against 0.21 to 0.28 across strides 36 to 44 -- and identical on all 27
levels. Here the peak is 0.09 to 0.22 excess and moves between levels and
spans, so the two should not be treated as the same kind of claim.
2026-10-11 04:03:50 +02:00
ReaperOfVeriod d1190a3343 Find a 40-byte period in the sound bank SBlk, on all 27 levels
Range measured: each level's SBlk region -- the descriptor table between
sblk_offset and daw_offset, from the sound bank header rather than from
core_data. 17-20 KB per level.

Method: peak-stride autocorrelation with a matched control, the same bytes
shuffled with the same seed on every level.

The peak is at exactly 40 bytes on all 27 levels, every one with excess above
0.2. On level 0 the peak is sharply isolated: 0.454 excess at stride 40
against 0.207 to 0.278 for strides 36 through 44, so it is a period rather
than a broad tendency.

This is the first structural result about the sound bank beyond the container
framing already committed.

Record counts are 487, 443, 428, 494 and 447 whole records on levels 0, 1, 2,
13 and 26, and the region is not an exact multiple of 40 on any level --
19484 leaves 4 bytes over -- so 40 is a period rather than a record size with
no remainder.

Unexplained: which byte positions are constant varies by level. Level 0 has
7, 11, 31 and 35 constant; level 1 has 15, 19, 23, 35 and 39; level 2 has 7,
23, 27 and 31. A fixed 40-byte layout would share its constant positions, so
either the records have a variable tail or 40 is a period in something the
records sit inside.

Also measured: DAW shows no stride above threshold against its control,
consistent with ADPCM nibble-packed audio and with the container framing being
the only part worth decoding. gameplay.bin peaks at 16 bytes. gs_ram.bin has
almost no stride structure but a lag-8 correlation of +0.503, so it is smooth
rather than tabular.
2026-10-11 03:56:34 +02:00
ReaperOfVeriod 51e81b497e Show the sky sub-object shape holds across levels
Range measured: sub-objects of the sky region on levels 0, 1, 3, 7, 13 and
26 -- 25 sub-objects, each over its whole length.

Method: correlation at lag against the byte sequence, as before.

  lag 8      +0.53 .. +0.79   positive on all 25
  lag 64     +0.16 .. +0.63   positive on all 25
  lag 512    -0.13 .. +0.42   not universal
  lag 1024   -0.05 .. +0.37   not universal

Lag 8 and lag 64 hold on every sub-object of every level tested. The longer
lags do not, so the second scale seen in level 0's first sub-object is a
property of that data rather than of the format.

The sky sub-objects are all the same kind of thing: 8-byte units strongly
related to their neighbours, with a further relation at 64 bytes. That is the
first result about sky that holds everywhere rather than on one level. The
8-byte unit is still unnamed and the values still unidentified.
2026-10-11 03:55:06 +02:00
ReaperOfVeriod b5edbd1810 Show sky sub-object 0 is a slowly-varying sequence at two scales
Range measured: level 0, sky region +365760..+388880, 23120 bytes.

Method: Pearson correlation between the byte sequence and itself at
increasing lag, against a shuffled control. This asks whether consecutive
records are related, which the position-wise distinct-value counts could not
show.

  lag   1  +0.050      lag 256  -0.044
  lag   4  +0.560      lag 512  +0.304
  lag   8  +0.746      lag 1024 +0.247
  lag  16  +0.666      lag 2048 +0.079
  lag  32  +0.602      shuffled +0.003
  lag  64  +0.432
  lag 128  +0.140

The profile peaks at lag 8, decays to a zero crossing near 256, then rises
again to a second smaller peak at 512 before decaying. So records are related
within themselves and repeat every 64 records.

This settles the earlier "no constant substructure" reading. The records share
no fixed fields, but they are not independent either, and that was the thing
the distinct-value counts were failing to capture.

Slowly varying values, related within a record, repeating every 64 records, is
the shape of a keyframe or lookup table -- which is what sky data should be,
where a colour or fog parameter is sampled rather than computed per vertex.

Still not decoded: nothing names the values or the sampling. But the shape is
now known well enough for a decode attempt to aim at, rather than to hunt.
2026-10-11 03:53:49 +02:00
ReaperOfVeriod 0ba790df1f Correct the sky sub-object 0 live-byte figure; record the byte 7 anomaly
The previous commit said "a mean of 6.99 of every 8 bytes are zero". That
is inverted. 6.99 is the mean count of live bytes, and the records are nearly
full rather than sparse. The live-byte histogram is 0:15, 2:1, 4:31, 5:238,
6:461, 7:1030, 8:1114.

That error also invalidated the reasoning built on it in this session's next
message, where the high zero-count was used to explain why the differential
analysis found no constant positions. There was no zero-density to explain.
The differential result stands -- all 8 positions vary -- but for the plain
reason that the records are dense, not because of any artifact.

New finding from re-examining it: byte 7 takes only 14 distinct values where
bytes 0-6 take 142-204. Its common values are 0x00 (1378), 0xff (806) and
0x80 (592), which are the high bytes of signed 16-bit values. That is
consistent with byte 7 being an MSB rather than ordinary data. Unexplained.

Also tried and recorded as not helping: a signed 16-bit reading spans
-32743..32633, near the full range, with no per-position structure (533, 371,
401 and 187 distinct at positions 0-3).
2026-10-11 03:52:40 +02:00
ReaperOfVeriod 3855169d48 Characterise sky sub-object 0: sparse, with duplicated entries
Range measured: level 0, sky region +365760..+388880, 23120 bytes, read as
2890 records of 8. The matched-control autocorrelation puts the peak at
stride 8 (0.217 real, 0.000 shuffled), so 8 is the unit to describe.

Composition: 1185 distinct u32 values across 5780 words; a mean of 6.99 of
every 8 bytes are zero, so most records are sparse; and 549 of 2890 adjacent
pairs, 19%, are exact duplicates of each other. The most common 8-byte record
appears 283 times and is d7d7d780 d7d7d780 -- a 4-byte value stored twice.
Others follow the same shape: 00051980 00051980, 00082580 00082580.

Not decoded. Sparsity with repeated entries is what a keyframe or index table
looks like, but nothing here establishes that, and the duplicated-4-byte shape
may just as easily be two adjacent records sharing a value.

Recorded as a characterisation with its range rather than a finding, because
that is what it is. The useful part is that the next attempt does not have to
re-derive the composition to know whether a decode is plausible.
2026-10-11 03:45:01 +02:00
ReaperOfVeriod a82e4a711f Measure sky sub-objects across levels; the stride table was over-fitted
Range measured: every level's sky region, from the count field at +0x0c
through the sub-object offsets at +0x20, each sub-object over its first
16384 bytes.

Method: peak-stride autocorrelation per sub-object with a matched control --
the same bytes shuffled, seeded per level and index.

Field note: the sub-object count is a u32 at +0x0c, not +0x0a. Reading +0x0a
gives 327,680 on level 0, which is not a count. A brief probe used the wrong
offset and read as though it were a count of 5; it got the right offsets by
accident because the offset table starts at +0x20 either way.

Level 0 has five sub-objects at 365760, 388880, 396432, 406784 and 414032,
of lengths 23120, 7552, 10352, 7248 and 1536, each with structure well clear
of its control.

But the strides do not repeat across levels:

  obj 0:  4 (x6),  8 (x13), 32, 288
  obj 1: 16 (x3), 32 (x6),  288
  obj 2:  8,     16 (x5), 256, 288
  obj 3:  8,     16 (x6), 216
  obj 4:  8,     16 (x3), 512 (x4)

So the per-index stride table read off level 0 alone was over-fitted to one
level, and is withdrawn. What is consistent is weaker and more honest: every
sub-object sits above its matched control, and most peak at 4, 8, 16 or 32,
suggesting 16- or 32-bit records whose stride varies with content.

Level 2's header is still genuinely different: its count field reads 196,617
and is unusable while every other level yields a plausible count.
2026-10-11 03:35:39 +02:00
ReaperOfVeriod 98c0d1ab3c Re-verify the 368 stride with a control matched to its data
The sky autocorrelation turned out to be inflated by a control that did not
match the data, so the 368 result was re-checked the same way rather than
being left on the evidence I now know to be weak.

Range measured: each level's post-shrub region start, first 65536 bytes.

Method: as before, but the control is the same bytes shuffled rather than
uniform random. Shuffling preserves the value distribution and destroys only
the ordering, which is what a control for this statistic needs to do.

Level 0's region is evenly distributed -- 256 distinct byte values, most
common only 15.6% -- so uniform random was the appropriate control here, and
the shuffled control independently confirms it, reading 0.0000 at strides 340,
368 and 4 against the real data's 0.1110 at 368.

Recomputed across all levels with matched controls: 24 of 24 peak at exactly
368, every one with excess above 0.05.

So the 368 period stands, now with a control appropriate to its data. The
contrast is the useful part: the same statistic, asked the same question of
two regions, gave a real answer in one and an inflated one in the other, and
the only thing that separated them was whether the control matched the byte
distribution.
2026-10-11 03:34:24 +02:00
ReaperOfVeriod 462d0eda5a Correct the sky control and the image extent
The 256-byte period in the sky region stands, but the evidence I gave for it
was badly chosen and one figure in it was wrong.

The control was uniform random bytes, which read flat at 0.0000 and made the
0.772 match rate look like an enormous signal. It is not: 91.8% of the
region's bytes are the same value, 0x07, so a stride-256 comparison matches
trivially wherever both positions are 0x07.

The correct control is the same bytes shuffled, which keeps the value
distribution and destroys only the ordering. It reads 0.512 at stride 256,
not 0.0000. The real excess is +0.26 -- a genuine signal, and much smaller
than reported.

The extent was wrong for the same reason. Measured against the matched
control, the excess over the value distribution is:

  +0 .. +49152    +0.38 .. +0.45
  +53248          +0.26
  +57344          +0.07
  +61440          -0.13

so the periodic data ends between +53248 and +57344, rows 205 to 224, not at
+60160. The earlier number came from a raw threshold with no distribution
control, and a byte value covering 91.8% of the data drags such a threshold
to wherever the dominant run happens to stop.

So: the period is real and cross-checked, the image dimensions are not. Any
"256x236" from the previous commit should be discarded.

The general lesson is the one AGENTS.md asks for and this missed -- a control
has to match the thing being controlled for. Uniform random was the wrong
reference for data with a dominant byte value, and it made a weak result look
like a strong one.
2026-10-11 03:33:54 +02:00