282e633a7c90e2852005c8cf77790539fcfcb8b0
61
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |