Commit Graph
227 Commits
Author SHA1 Message Date
ReaperOfVeriod 349b498ddd r2 is never written in the enclosing function, and it has no callers
The trace eca1a16 asked for. The call site 0x3a7d88 sits in the function at
0x3a7c30, 580 bytes.

r2 is never written there: scanning all 580 bytes for any instruction storing into
r2 finds none. So it arrives from outside the function.

And 0x3a7c30 has no jal callers anywhere in level-0 .text, and is not a lvl.vtbl
handler -- level 0's vtbl has 233 records and 185 distinct handlers, and neither
0x3a7c30 nor 0x3a8060 is among them.

That rules out the tidy story, which was the obvious guess given r2 arrives
unexplained: the vtbl dispatcher calling it with a class pointer in v0. What
remains is an indirect jalr call, a dispatch table in data, or code that is never
called. Not distinguished; the question for anyone continuing is whether
0x3a7c30 is a jalr target and from where.

The chain is now ? -> 0x3a7c30 (no static caller) -> 0x3a8060 -> addiu r17, r18,
0xc0. Every remaining question is about control flow and register provenance, not
counts, which is where this stretch's method stops working.

284 passing with the disc present, clippy clean.
2026-10-14 05:44:46 +02:00
ReaperOfVeriod eca1a16b17 Call sites: one caller, and it does not compute base + 0xc0
0x3a8060 has exactly one call site, at 0x3a7d88:

    0x3a7d84  addiu  r18, r2, 0x202d      r18 = r2 + 0x202d
    0x3a7d88  jal    0x3a8060

No 0xc0 in the eight instructions before the call, and r18 is built from a
different offset. So the 0xc0 inside the callee is a field offset applied to a
pointer the caller already built, not a chunk offset the caller resolved.

Two readings remain: r18 is core_data, making r18+0xc0 equal &moby_classes, which
fits since level 0's moby_classes.offset really is 0xc0; or 0xc0 is a member
offset in an unrelated structure and the match is coincidence.

Settling it needs the provenance of r2 at the call site -- real dataflow, not
counting. That is where this stops.

Recorded in docs/plan/level-assets.md. The previous commit's doc edit failed
silently on a line-break mismatch, so this appends rather than replaces.

284 passing with the disc present, clippy clean.
2026-10-14 05:41:48 +02:00
ReaperOfVeriod cee17c95b1 Disassembled: 0xc0 is an address offset, and the walk is 16-byte strided
Closes the ambiguity 05d9bf6 raised. At 0x3a8060:

    263200c0   addiu  r17, r18, 0x00c0     r17 = r18 + 0xc0
    263000d0   addiu  r17, r16, 0x00d0     r17 = r16 + 0xd0
    262400e0   addiu  r17, r4,  0x00e0     r17 = r4  + 0xe0
    3c013f80   lui    r0,  r1,  0x3f80     the actual float constant: 1.0f

0xc0 is an address offset, not a float constant. The float constant is in the same
function and is 0x3f80, where it belongs -- so the ambiguity does not arise here,
and the reading of 0xc0 as moby_classes.offset survives.

The three offsets are 0xc0, 0xd0, 0xe0: sixteen bytes apart. The function walks a
table of fixed-stride structures and indexes the field at offset 0xc0, which is
what a class-table lookup looks like.

WHAT THIS DOES NOT SETTLE

That 0x3a8060 is the moby class loader. It indexes a 16-byte-stride table at
offset 0xc0, which is consistent with a moby class walk, with a different
structure that also lives at 0xc0, and with ordinary field access on an object
handed in by the caller.

The entry code sets a 64-byte frame and saves r16 through r20, and the three
indexes come off three different registers -- r18, r16 and r4. So the walk is over
arguments, not over a global. That is informative in itself: a loader would more
plausibly be handed the chunk by its caller than re-derive core_data + 0xc0 on
every call. Which also means the chunk reference is in the *caller*, and that is
where the next step is.

NEXT STEP, narrow and concrete: trace where r18 and r4 come from, and find the
call sites of this function. If a caller does `addiu r, base, 0xc0` and passes it
in, that caller is doing the chunk lookup and this function is the per-class work
inside it.

Worth recording how long the ambiguity took to close. It was raised one commit ago
and resolved by reading twelve instructions. Five commits before that the same
constant was reported as a "real shortlist", and two before that as a size
artifact. Every one of those states was arithmetically correct; only this one was
the right question, and it took looking at the operands rather than counting them
to arrive at it.

284 passing with the disc present (23 + 19 + 64 + 156 + 22; disc binary 35.00s),
clippy clean.
2026-10-14 05:40:08 +02:00
ReaperOfVeriod 05d9bf6cd7 The 72 concentrated candidates, and a float-constant ambiguity
Looking at the 72 candidates with fewer than 16 distinct immediates, the sets are
full of values that look like lui high halves: 0x3f80 is the high half of 1.0f,
and 0xff40/0xff70/0xff80/0xffc0 are negative high halves. 0xc0 << 16 is 0x00c00000,
a small float.

So 0xc0 in these functions may be a float constant rather than a chunk offset.
The neighbour control cannot tell the difference, because 0xb8 and 0xc8 are
equally plausible float high halves and are rare for their own reasons.

Also recorded, and explicitly not as a finding: exactly 1 of 394 candidates
contains another chunk offset, and none of the 72 does. That sounds like evidence
of moby-specificity and is not -- the other chunk offsets are 0x1e80 and above and
are globally rare (0 to 2 functions each, measured in 3ea5ab7), so their absence
here is exactly what rarity predicts.

What survives is the enrichment fact from a2f8c92. What does not survive is
reading 0xc0 as a chunk offset. There are now two live explanations, a moby chunk
reference and a float constant, and only disassembly separates them.

Cheapest test: 0x3a8060, 148 bytes, six immediates. If the lui carrying 0xc0 is
paired with an ori it is a float; if it feeds an addiu computing base + 0xc0 it is
the chunk.

284 passing with the disc present, clippy clean.
2026-10-14 05:36:13 +02:00
ReaperOfVeriod a2f8c924b4 moby_classes.offset enrichment is size-independent: 13x to 72x at matched size
Runs the control 06531fb named. It settles the question that commit left open,
and the answer is that the enrichment is real.

Matching on immediate-set size, against the best of the sixteen adjacent values:

    bucket      n   0xc0   best neighbour   ratio
       0-15  1,082     72   0xb8:1             72.0x
      16-31    713    119   0xc8:4             29.8x
      32-47    291    104   0xb8:3             34.7x
      48-63    119     39   0xb8:2             19.5x
      64-79     59     31   0xc8:2             15.5x
      80-95     25     13   0xb8:1             13.0x

Enriched in every bucket, and the ratio does not decay as sets grow. That is the
opposite of a pigeonhole artifact -- a volume effect would shrink the ratio in the
big buckets, and it rises instead.

The load-bearing row is the smallest: 72 of 1,082 functions carrying fewer than 16
distinct immediates contain 0xc0, where the best neighbour appears once. A set that
small cannot produce that by count alone.

What this revives. The 300 candidates with 48 or fewer distinct immediates are not
noise: at matched size, neighbours hit 0.4% to 1.4% in those buckets where 0xc0
hits 35%. A function using fewer than 16 constants, one of which is 0xc0, uses
that offset deliberately. Those are the shortlist.

Why this took four commits to establish. 3ea5ab7 measured 394 against neighbours
1-14 and called it a shortlist. 06531fb showed the rate rises with set size and
said the 394 was a list of large functions. Both were right about their own
measurement and neither had the bucket-matched comparison, which is the only form
that separates "the constant is special" from "big functions contain more
constants". Twenty-two probes this stretch, and the thing that finally settled it
was not a better measurement but adding the right comparison to a measurement
taken three commits earlier.

Caveat unchanged: presence of an immediate is not proof the chunk is read there.
It is proof the constant is used, which is all either test asserts.

284 passing with the disc present (23 + 19 + 64 + 156 + 22; disc binary ~35s),
clippy clean.
2026-10-14 05:34:42 +02:00
ReaperOfVeriod 06531fb66c The 394 does not survive: 0xc0's rate rises with immediate-set size
Ran the size-independent statistic db1e69c asked for, and it confirms the
confound. Bucketing level-0 functions by distinct-immediate count, the rate
containing 0xc0 is:

    0-15   72/1082    6.7%      64-79   31/59   52.5%
    16-31 119/713   16.7%      80-95   13/25   52.0%
    32-47 104/291   35.7%     112-127   3/7    42.9%
    48-63  39/119   32.8%     624+      1/1   100%

Monotone from 6.7% to over 50%. Pigeonhole: a function with hundreds of distinct
immediates has more chances to contain any particular value.

So the 394 is largely a list of large functions and '394 of 2,323' was never a
shortlist. db1e69c was right; 3ea5ab7's framing was wrong and is corrected here.

This does not retract the neighbour result. 0xc0 being enriched over 0xb8-0xc8,
and 0xc0 being enriched in bigger functions, are separate claims and both hold.
What it retracts is the inference that the 394 are candidates for anything.

The control that would settle it: 0xc0's rate against a specific neighbour's rate
within the same set-size bucket. Not measured, and named so the next attempt
starts there rather than re-ranking by size.

No test -- the bucket rates are the finding, and an assertion on any single
threshold would be arbitrary.
2026-10-14 05:32:34 +02:00
ReaperOfVeriod db1e69c0ab Ranking the 394 by size is backwards; record the confound
Ranking the moby_classes candidates largest-first is the wrong first cut. The
top entries are 34,316 and 26,432 bytes with 631 and 421 distinct immediates,
median candidate 852.

A function with 631 distinct immediates is far more likely to contain any given
value, 0xc0 included, than one with 80, purely by pigeonhole. Sorting by size
therefore selects for the functions most likely to be false positives, and the
34,316-byte outlier is probably a mis-bounded extent -- the decomp corrects
three level-00 entries -- rather than a 34 KB loader.

The 394 needs a size-independent statistic: whether a function contains 0xc0 more
often than its immediate count predicts, not whether it contains it at all. Not
measured.

Recorded as a negative about the method rather than committed as progress. No test
-- there is no assertion here to make.

283 passing with the disc present, clippy clean.
2026-10-14 05:30:07 +02:00
ReaperOfVeriod 3ea5ab799f moby_classes.offset is referenced by 394 functions; its neighbours by 1 to 14
First positive result of the loader hunt, and it exists only because of the
control that two commits ago I said was missing.

Across 2,323 level-0 functions of at least 128 bytes -- extents from the decomp's
functions.tsv -- the immediate 0x00c0, which is moby_classes.offset, appears in
394 functions. Every value adjacent to it appears in 1 to 14.

    0xb8:8  0xb9:2  0xba:2  0xbb:2  0xbc:3  0xbd:5  0xbe:5  0xbf:3
    0xc1:3  0xc2:3  0xc3:2  0xc4:1  0xc5:2  0xc6:3  0xc7:1  0xc8:14

This is what 27891ce was asking for. 0xc0 is small and small immediates are
common -- 17,699 occurrences across the program, against 200,784 for 0x0001 -- so
the raw frequency was worthless. The neighbours have the same magnitude and none
of the frequency, which is the only reason 394 means anything rather than being an
artifact of the value being short.

A second control points the other way, so the two bracket it: the other five
level-0 chunk offsets are larger values and are referenced 0 to 2 times each.
Shrub_classes 1, tfrag_textures 2, moby_textures 2, tie_textures 0, shrub_textures
2. The chunk offsets a loader would use are referenced about once. moby_classes is
referenced 394 times. The program leans on this constant heavily.

The previous commit's 410 candidates were not a shortlist. These 394 are, because
the number is now separated from magnitude rather than merely stated.

WHAT THIS DOES NOT SAY

An immediate appearing in a function is not proof the chunk is READ there. It is
proof the constant is used, which is the claim the test asserts and the only claim
it makes. It does not say which of the 394 is the loader, or that the loader is
among them. Stated plainly because the 394 is the kind of number that gets
quoted without its caveat and then built on.

Also measured, negative: zero functions contain both the moby_classes and
tie_classes offsets, so nothing indexes both chunk tables by immediate. The moby
loader does not reach the tie table the same way, if it reaches it at all.

Boundaries: level 0 only, "level 0" meaning the function's first listed place is
level 00, and a 128-byte size floor -- 59957a0 recorded 20-byte "functions" that
are mis-bound extents.

283 passing with the disc present (23 + 19 + 63 + 156 + 22; disc binary 35.23s),
clippy clean.
2026-10-14 05:28:34 +02:00
ReaperOfVeriod 59957a02a2 Attribute the 0xc0 immediate sites to functions: 410 candidates
The decomp's functions.tsv makes this cheap -- 3,835 level-0 functions with start
and size, so each immediate-bearing instruction is attributable to its container.

410 of 3,835 functions (10.7%) contain one; sizes 20 to 34,316, median 808.

Ten per cent is what a common small immediate buys, so this confirms 56b00fb
rather than narrowing anything. 410 is not a shortlist. What it does establish is
that the attribution works and the decomp's extents are usable for it.

The loader hunt now has a concrete next step: of those 410, the ones that also
touch a class span. That is dataflow, not counting, and it is where this resumes.

Caveat recorded: a 20-byte 'function' holding the immediate is a mis-bound extent
whose tail happens to include another function's instruction. Sizes need a floor
before the set is used.

No test -- an attribution count with no assertion behind it would only make the
410 look like a finding.
2026-10-14 05:23:14 +02:00
ReaperOfVeriod 56b00fb305 Fix the immediate scan by filtering on opcode; still no signal
Saying the previous method 'cannot tell' was right but the reason deserved a step
rather than a hand-wave: it is fixable by counting immediates only where they are
immediates, which needs an opcode filter and no disassembler.

All 27 levels: 11,911,320 instructions, 2,985,897 carrying an immediate
(addiu/lui/ori/xori/andi/sltiu), 13,813 distinct immediate values.

    moby_classes 0x00c0   17,699
    tie_classes  0x1e80       17

And now there is a control the raw version never had -- the program's most
frequent immediates are 0x0001 (200,784), 0x001b (149,259), 0x0010 (137,523),
0xffff (98,312). 0xc0 at 17,699 sits below the top of that small-value cluster
rather than standing out, so it is not a distinctive reference.

Result unchanged from the raw scan and now defensible: no signal. The loader is
not findable by frequency, and the remaining route is structural dataflow --
functions touching both the chunk offset and a class span.

No test: asserting that an offset is unremarkable is not an invariant worth
locking, and the two numbers are already recorded with their denominator.
2026-10-14 05:17:47 +02:00
ReaperOfVeriod 27891ce5b4 Cross-reference scan by raw immediate: the method cannot tell
Sought the moby class loader by counting raw u16 occurrences of each chunk offset
in .text. moby_classes 0xc0 hits 49,230 times, tie_classes 0x1e80 hits 111 --
and that contrast means nothing.

Small u16 values are simply common: the most frequent value in .text occurs
1,834,535 times. A two-byte value that is frequent by construction beats a rarer
one regardless of what either references, so the comparison has no shared
denominator. Rule 3a again, one level below where it has bitten before -- not the
measurement being wrong but the comparison being uninterpretable.

Recorded as a methodological negative rather than a finding. Doing it properly
means counting immediates only where the instruction decoder says they are
immediates, which is what tools/overlay-anchors exists for.

No test added: a test asserting a meaningless comparison would make the next
reader trust it.
2026-10-14 05:15:07 +02:00
ReaperOfVeriod 743107e864 moby_anim: the class table is not the loader
Ruled out the cheap approach: moby_classes (offset 0xc0, count 238 in level 0)
holds float-shaped values, and only 120 of the first 4,096 u32s land inside the
overlay .text against ~259 expected by chance. Fewer than chance, so the loader
is not dispatched from a pointer here.

Not concluded what the 238 entries are -- the float reading is suggestive but was
taken at one stride on one level.
2026-10-14 05:13:02 +02:00
ReaperOfVeriod 5a60a2920c Remove two dead counters; clippy clean again
Follow-up to the note in 6d26da3 that two unused-variable warnings in the
sound_bank and overlay tests were pre-existing and deliberately left alone.

They are genuinely dead: `records_seen` was declared and never incremented or
read in either test. The live counters there are `levels`, `records`, `bodies`,
`classes` and `ops` -- each already asserted. Removed.

Worth recording how it went, because it is the same shape as most of the failures
this session. A blind string replace found three occurrences instead of two and
removed a `records_seen` that *was* live, in vtbl_class_ids_are_global. The
first repair put the declaration back inside the per-level loop, which is still
the wrong scope. Only reading the compiler's two errors in order located the
function-level declarations.

Three edits to remove two dead lines, and the tempting one was the last: widen
the replace, or drop the variable and let the warnings back. Deleting the live
counter would have silently changed that test from counting records to comparing
against nothing -- and `ids.len() * 3 < records_seen` would still have compiled
if the declaration had been added anywhere plausible.

The check that caught it was not a test. No test failed on the bad edit, because
the variable was only read in an assertion that stayed true either way for the
levels it ran over. Clippy caught the compile error; nothing else would have.

282 passing with the disc present (23 + 19 + 62 + 156 + 22; disc binary 34.87s),
clippy clean.
2026-10-14 05:10:58 +02:00
ReaperOfVeriod 6d26da3630 The control fails my own claim: 0x1c/0x34 select nothing distinctive
2ff2abb ended by implying that the class-header offsets point at identifiable
content. I said the next step would be a control. Here it is, and it fails the
claim.

Adjacent-equal-byte rate over 64 bytes, 1,864 classes:

    0x1c     0.12688
    0x34     0.12106
    control  0.14109    (the same offset + 0x2000)
    random   0.00391

All three are ~30x above the random baseline, so the region is structured data
and not noise. That part stands. But 0x1c and 0x34 are no more structured than an
arbitrary offset in the same blob, and 0x34 is slightly *less*.

So the hex fragments in 2ff2abb were three hand-picked examples from a structured
region. "They look like compiled shader programs with small-integer parameters"
said something about this part of core_data, not about what those fields address.
That is exactly the rule 4 failure -- a pattern that reads as meaningful with no
control to test it against -- and I wrote it anyway while explicitly declining to
name a format two paragraphs earlier.

RETRACTED: the suggestion that the offset targets are identifiable content.

Unaffected, because it was measured differently: the offsets are base-relative,
non-zero, in range on 1,864 of 1,864 classes, and always land past the class's
own tie packet blocks. What they address is still unknown. The earlier finding
that the packet's control region is not a tag stream also stands -- that was
measured by uniformity within the packet, not by comparison to elsewhere in
core_data.

A TEST THAT CAUGHT ITS OWN AUTHOR

The first version asserted |0x1c - control| < 0.02 symmetrically, and failed on
0x34 at a gap of 0.0200. The correct response was not to loosen the number until
it went green. The claim being guarded is "these offsets select distinctive
content", and a gap in the *other* direction does not support it -- both offsets
came out below control. So the assertion is one-sided, and the comment says why,
including that the symmetric form was the wrong shape.

That is the AGENTS 3b discipline working in the direction it is meant to: a
failing assertion is information about the assertion.

282 passing with the disc present (23 + 19 + 62 + 156 + 22; disc binary 35.16s),
clippy clean.
2026-10-14 05:06:57 +02:00
ReaperOfVeriod 2ff2abbd32 Two class-header words are base-relative offsets pointing past the tie packets
Chasing where the AD-GIF is, since f43a1c2 established it is not in the packet.
Two of the undecoded class-header words are offsets.

base + 0x1c and base + 0x34 land inside core_data on 1,864 of 1,864 classes, so
both are relative to the class base rather than absolute, and both are non-zero
on every class. Both also land **past** that class's own tie packet blocks on
every class, so they address per-class data held elsewhere in core_data rather
than the tie geometry.

That is consistent with f43a1c2: if the packet's own control region is not a tag
stream, the per-class draw state is referenced from somewhere else.

WHAT IS THERE, AND WHAT IS NOT CLAIMED

The bytes are structured, not random -- class 3 shows a fixed-stride table with
`80 01 00` recurring, class 2's field is a monotone byte table, class 5's has
`38 38 38` and `ff 00 ff ff`. They read like compiled shader programs with
small-integer parameters, or lookup tables.

**Not identified, and deliberately not named.** None of those three fragments has
been run against a control, and all three were picked because they looked like
something. That is precisely the finding rule 4 refuses, and having spent this
session on four retractions I would rather record an honest "structured,
unidentified" than a plausible format that has not earned it. If any of these
turns out to be a shader program the next step is a control, not a better-looking
hex dump.

ALSO SETTLED

0x24 is demonstrably not an offset: only 17 of 1,864 of its values land in range
for core_data. Recorded as packed data. 0x28, 0x2c, 0x38 remain high-entropy and
unexplained.

281 passing with the disc present (23 + 19 + 61 + 156 + 22), clippy clean.
2026-10-14 05:03:34 +02:00
ReaperOfVeriod ff9699c3c7 The tie class header's undecoded tail: two reserved words, a float, and a control that says no
TieClassHeader skips 0x1c..0x44 -- forty bytes per class, 1,864 classes, "none of
it matches RAC2 yet". Profiled each of the ten words instead of reading them.

THE PROFILE, PER WORD

    0x20   reserved -- zero on 1,864 of 1,864
    0x30   reserved -- zero on 1,864 of 1,864
    0x1c   1,390 distinct, 0x1e0..0x3cd60          looks like an offset
    0x24   1,571 distinct, high entropy
    0x28   640 distinct, 0x00100000..0x1000013d    two bytes packed
    0x2c   1,610 distinct, high entropy
    0x34   1,331 distinct, 0x190..0x31e20           looks like an offset
    0x38   441 distinct
    0x3c   SIX distinct: 0 (1,384), 16 (435), 48 (21), 32 (18), 2 (3), 4 (3)
    0x40   a float -- 0.00208 to 10.84, median 0.30, no negatives

0x40 is decidable and it is now a hard assertion: a positive range across two
orders of magnitude with no negatives and no zeros is not what an integer looks
like. Bounding sphere radius or class scale; not distinguished, and the doc says
so rather than picking one.

THE CONTROL THAT PREVENTED A WRONG CLAIM

0x3c has six values, is zero on 74% of classes, and sits next to a doc comment
listing "the texture count, ad-gifs, the bounding sphere and the class scale". The
obvious next move is to call it the ad-gif count -- especially having just spent a
commit establishing that the AD-GIF is not in the packet, so an ad-gif count
would be satisfying.

Tested it: mean bfc_distance per 0x3c bucket is 7.80, 6.56, 11.07, 8.03, 6.81,
6.11. Flat. It does not track the shader count, so it is not the ad-gif count.
The test now asserts that flatness as a control, so if 0x3c ever starts
correlating with bfc_distance the assertion fires rather than the coincidence
being noticed later and mistaken for a decoding.

That is the rule-4 pattern working: a statistic that cannot tell the hypothesis
apart from nothing must not be used to support it. Six distinct values and a
dominant zero *feel* like a count, and feel is exactly what is not evidence.

STILL UNIDENTIFIED

0x1c and 0x34 look like offsets but are unverified as such -- only 17 of 1,864
0x24 values land in range for core_data, so that one is not an offset and is
recorded as packed data. 0x24, 0x28, 0x2c, 0x38 are high-entropy and unexplained.
None of them is guessed at.

280 passing with the disc present (23 + 19 + 60 + 156 + 22; disc binary 35.38s),
clippy clean. The two unused-variable warnings in the sound_bank and overlay tests
are pre-existing and confirmed present before this change; left alone rather than
folded into an unrelated commit.
2026-10-14 05:01:40 +02:00
ReaperOfVeriod f43a1c2e6e bfc_distance is not the control region's length; the AD-GIF is not in the packet
Follows 9bb04cc. The control region is now characterised, and the second half of
the answer is a correction to a field doc that has been wrong since the record
was first parsed.

WHAT THE CONTROL REGION IS

Per-record, not shared state. 12,294 distinct blobs across 13,042 records, mixed
within 2,410 of 3,549 classes, and different between LOD 0 and LOD 1 on all 500
classes that have both. It varies per primitive.

Partitioned by its own length, it is not uniform in character, and the pooled
count hid that:

    vert_ofs  48 bytes  (3 qw)      5 distinct blobs
    vert_ofs  64 bytes  (4 qw)  3,175
    vert_ofs  80 bytes  (5 qw)  4,309
    vert_ofs  96 bytes  (6 qw)  3,281
    vert_ofs 112 bytes  (7 qw)  1,523
    vert_ofs 128 bytes  (8 qw)      1

The shortest and longest are effectively constant -- five blobs, and one -- while
everything between is per-record unique. That is a shape, not a noise floor, and
it is invisible unless the measurement is partitioned by length.

THE CORRECTION

bfc_distance == 5 * shader_count holds on every record, and the field doc has
always read "quadwords of ad-gif data" -- which read as a packet-relative length
would make it the control region's size.

It cannot be that. bfc_distance is 5..20 quadwords; vert_ofs, the control region,
is 3..8.

Pooled, they overlap on 9,315 of 15,219 records -- 61%, a number describing
nothing. Partitioned by shader_count, as AGENTS 3a requires, it is exact:

    shader_count 1: 2,772 of 8,676 exceed  (32%)
    shader_count 2: 4,649 of 4,649 exceed  (100%)
    shader_count 3: 1,729 of 1,729 exceed  (100%)
    shader_count 4:   165 of   165 exceed  (100%)

Without exception for two or more shaders. For one shader it exceeds exactly when
vert_ofs is below 5, which is that 32%.

**The AD-GIF is not stored in the tie packet.** The packet is control, vertices
and scissor, in that order. bfc_distance is a per-record quantity tied to the
shader count whose meaning is still not established, and the "quadwords of ad-gif
data" reading of it is wrong. The doc now says so and says why, rather than
carrying a plausible description of a field whose meaning nobody has established.

This is the fourth retraction today and the second today found by partitioning
rather than by a test failing. The pooled number was not merely less precise than
the partitioned ones -- it pointed the other way, because 61% looks like noise and
100% looks like a boundary. Had I accepted the pooled figure I would have written
"sometimes" and moved on.

59 disc tests, 278 total across 23 + 19 + 59 + 156 + 22 with the disc present
(disc binary 35.31s), clippy clean.
2026-10-14 04:57:41 +02:00
ReaperOfVeriod 9bb04cca7b The tie control region is not a VIF or GIF tag stream; and vert_ofs is mis-named
Went after the GS's GIF packets in the tie AD-GIF region. Got a negative result,
a new universal invariant, and a retraction of my own first probe, which reached
the right answer for the wrong reason twice.

THE NEGATIVE RESULT

Tried the leading `5 * shader_count` quadwords of each packet (the
`bfc_distance` field's stated meaning) as VIF tags -- command in bits 0-6, size
in 7-14, mark in 15 -- and as GIF tags -- type in the top byte of word 1, length
in bits 0-2 of word 0. Both uniform. Over 13,042 records: 10,443 distinct values
of word 0, no GIF tag type above a count of 2. That is the control result rule 4
asks for, and it says stop.

THE RETRACTION

The first probe scanned `bfc_distance` quadwords, which for most records
overruns `vert_ofs` and runs into the vertex data. Pooling a control region with
vertex positions -- two populations with different structure -- gives a uniform
answer, so "not a tag stream" came out right from a measurement that was
measuring the wrong bytes. That is exactly the rule 3a failure, and I only caught
it because the hex dump I took next looked structured while the histogram said
random.

Re-measuring quadword 0 alone, on its own population, gives the same uniformity.
The conclusion survives; the reasoning did not, and both are recorded.

THE PARTITION, AND A NEW INVARIANT

The hex dump showed the packet layout directly:

    q[0 .. vert_ofs)            control        3..8 quadwords, peak at 5
    q[vert_ofs .. +vert_size)   vertices
    q[vert_ofs+vert_size ..]    scissor

and `scissor_ofs == vert_ofs + vert_size` holds on **15,219 of 15,219** records
across all 27 levels, including every one whose strip_count is non-zero. That is a
derived quantity stored explicitly, and it holds everywhere -- which is the check
that confirms the three regions really are laid out the way the existing packet
length formula assumed. It is now a hard check in invariants_hold(), which is
something the previous four could not be.

The fixture in tie.rs failed the moment I added it: it had vert_ofs 4 and
vert_size 0x20 with scissor_ofs 0x30, which is 4 + 0x24. The synthetic data was
never constrained by a relation that did not exist yet. Fixed to 0x24.

vert_ofs IS MIS-NAMED

It is not a vertex offset. It is the *length* of the leading control region, and
the vertex data begins at that many quadwords into the packet. The field name
stays because the on-disc order is fixed and everything already written about this
record says vert_ofs, but the doc now states what the value is and gives the
distribution.

STILL UNKNOWN

What the control region contains. Not standard DMA tags; possibly pre-merged GS
register writes in another framing, or per-class state rather than per-primitive
draw data. Recorded as an open question rather than guessed at.

23 + 19 + 58 + 156 + 22 = 278 passing with the disc present (disc binary 35.67s),
clippy clean.
2026-10-14 04:51:43 +02:00
ReaperOfVeriod f0bc1fbdf4 State which GS rules are wired and which are not
A small doc change with a specific reason.

gc-engine's sampler sat behind a comment claiming it clamped while the code was
`..default()`, which is Repeat. That was caught only because I went looking for
where clamp01 should be used and found the answer was "somewhere that already
claims to do it". The generalisable failure is a reader seeing a rule defined in a
module called gs.rs and assuming the renderer applies it.

So the module now says which rules are wired and which are not:

    CLAMP wrap           yes    gc-engine image_of sets address_mode_u/v
    alpha 0x80 opaque    yes    texture::scale_alpha delegates here
    blend / alpha test    no    bevy AlphaMode cannot express both at once
    mip selection         no    only level 0 is decoded, mip_level is always 0

The last row is the one worth having written down. mip_level is a correct,
tested function that returns 0 for every input the renderer can currently give it,
and nothing in the code says so. It will start meaning something the day multi-level
textures are decoded, and until then it is indistinguishable from a mip selector
that works. That is the same shape as the sampler: a specific, plausible, untested
claim about behaviour sitting where nobody will re-check it.

passes_alpha_test and BlendMode are reference -- correct and tested, but not on the
draw path, and per 43e508a not expressible there in bevy's current enum. Saying so
is better than leaving them looking like pending integrations.

No behaviour change. 278 tests, clippy clean.
2026-10-14 04:46:16 +02:00
ReaperOfVeriod 43e508aa21 AlphaMode::Blend, not Mask -- the cutout-first ordering was moot
Sets the terrain material's alpha mode, which is the first draw-path change the
GS work has actually produced.

The material used `..default()`, which is AlphaMode::Opaque: every texel drawn
fully solid regardless of what its alpha said. Given 0.98% of texels on the disc
are fully clear, that means roughly 227,000 texels that should have shown
background are drawn as opaque geometry.

The choice is Blend, not Mask, and the reason is the histogram from 25d73b4:

- Mask(f32) is a *quantising* cutout. Below the threshold -> transparent, at or
  above it -> fully opaque. Feeding it the 1.08% partial texels would flatten
  every one of them to solid, discarding exactly the soft edges they exist to
  produce. 126 distinct values become two.
- Blend handles all three populations at once. A clear texel blends to invisible,
  a partial one stays smooth, an opaque one stays opaque.

Which retires the conclusion I gave two commits ago. I said cutout-first, on the
reasoning that the clear texels need discarding and a partial texel drawn opaque
is a failure nobody notices. But Blend does not need a cutout to hide those
texels -- a zero-alpha fragment is invisible by definition. The cutout was
solving a problem Blend solves for free, so the ordering was moot before it was
tested. Three commits on the alpha path and the second conclusion has now also
fallen; the surviving facts are the histogram and the rule that 0x80 is opaque.

One real fidelity gap, recorded rather than papered over: the GS can alpha-test
and blend the same primitive at once. bevy's AlphaMode is a single enum where
Mask and Blend are mutually exclusive, so it cannot express that. Where the
original combines them, this port has to pick one, and the choice will not be
free. That belongs in the Phase 5 frame-for-frame gate rather than being treated
as settled here.

Clippy clean, builds. No test added: this is a material parameter with no
headless assertion available, and asserting a bevy enum value would be a test of
the bevy API rather than of anything in this project.
2026-10-14 04:45:41 +02:00
ReaperOfVeriod 87e2367daf Retract the "cutout matters far more" claim; the counts do not support it
25d73b4 said:

    alpha testing (cutout) matters far more than alpha blending on this disc

and cited 0.98% fully clear against 1.08% partial as the evidence. Those two
numbers are the same size. The cutout path handles the fully-clear texels and the
blend path handles the partial ones, so the measurement I gave is if anything
marginally against the claim, not for it. The conclusion did not follow from the
number attached to it.

What survives is a different and weaker claim, and it is about failure shape
rather than texel count:

- a texel that should be discarded but is drawn puts geometry where there is
  none. That is visible from a screenshot and someone will file a bug about it.
- a partial texel drawn opaque is a slightly wrong soft edge. That is not visible
  and will not be reported.

So cutout first is still the right order, but the reason is which failure a human
notices, not how many texels are involved. Stated that way it is defensible;
stated the way I did it was an overclaim with a number attached to make it sound
measured, which is the specific failure mode rules 3 and 3a exist to prevent.

This is the fourth thing this session where a plausible implementation or claim
turned out to be wrong on inspection, and the second in two commits where the
error was in the reasoning rather than the code. Code errors get caught by tests
because the tests execute. Reasoning errors get caught by re-reading the number
the claim rests on and checking that it says what the sentence says.

Retracted and replaced rather than softened, per AGENTS 3a.
2026-10-14 04:44:37 +02:00
ReaperOfVeriod 1bd4e17819 The test count is identical with and without the disc; only the clock differs
An addition to AGENTS 3b, from nearly repeating the failure it describes.

Checking the count in 25d73b4, the suite came back 278 passing -- the same 278 as
always. But GC_DISC was not set for that run, so all 58 disc tests hit their
`require_disc!` early return and were counted as passed. The number was right and
the coverage behind it was not.

What distinguishes the two runs is the wall clock and nothing else:

    without GC_DISC   58 tests,  4.10s
    with GC_DISC      58 tests, 36.14s

Same test count, same "ok", same zero failures. A reader -- including me -- has no
way to tell those two lines apart. That is a sharper version of the 3b failure
than the filtered sum was, because no amount of reading the output carefully
distinguishes them. The duration does, and it is in the line already.

So 3b now says: if a run finishes suspiciously fast, the disc tests did not run.
And it keeps the standing instruction to set GC_DISC when touching disc-decoding
code, since 25d73b4 changed texture-adjacent code and the verification genuinely
needed the disc.

Re-verified properly: 278 passed across 23 + 19 + 58 + 156 + 22, disc present,
36.14s on the disc binary. Clippy clean.
2026-10-14 04:44:07 +02:00
ReaperOfVeriod 25d73b4fa8 What the disc says about GS priority: cutout matters, blending barely does
The alpha histogram from 777a6a8 had a consequence sitting in plain sight that I
had only used as a regression guard. It is a prioritisation input.

Measured over every decoded tfrag texture on all 27 levels:

    total texels            23,168,000
    fully opaque (255)      22,690,698   97.94%
    fully clear  (0)           227,497    0.98%
    partial (1..254)           249,805    1.08%
    distinct partial values            126

Two conclusions, and they point in opposite directions from the one I would have
assumed.

The 0x80 -> 1.0 rule is not an edge case to be got right eventually. It is what
22.7 million texels -- 97.94% of every pixel of terrain on the disc -- are
already doing. Getting it wrong is not a subtle shading difference, it is the
texture decode being wrong almost everywhere.

But blending is close to irrelevant at this level: partial alpha is 1.08% of
texels. A correct but imperfect blend path costs almost nothing visually; a
correct alpha *test* is what determines whether foliage, fences and cutaway
geometry exist at all. That reorders the remaining GS work, where I had been
planning to do blending and the cutout together.

The 126 distinct partial values are also evidence about what they are. If the
partial alpha were a blend ladder or a dither, it would populate a handful of
values. It populates all 126 evenly, which is what smooth alpha looks like --
soft edges and distance fade, not discrete states.

Caveat worth stating: these are tfrag textures, so this is a statement about
terrain. Moby and sky alpha may behave differently and are not covered by this
measurement.

Test count read from the raw result lines per AGENTS 3b, with the disc present:
278 passing, clippy clean.
2026-10-14 04:42:59 +02:00
ReaperOfVeriod 1982f545f1 AGENTS 3b: never report a test count a filter could have hidden a failure in
Rule 3b, requested after a commit went out with a failing test in it.

    cargo test -q --workspace | grep "^test result: ok" | awk '{s += $4}'

That sums the green test binaries and ignores the red one. A broken build still
prints a number, and the number looks entirely reasonable -- six healthy counts
added together is not a number anyone questions. I used that filtered sum as the
evidence of health in commit messages for the better part of a session, and it
was reporting green on a build that was not.

What actually happened is the part worth keeping. The count came back lower than
the previous run -- 100 instead of 278. That is a genuine signal and I explained
it away twice as an environment difference, the disc not being set, before
checking. Both explanations were plausible. Neither was tested. Only the
discrepancy was noticed, and noticing it is what surfaced the failure.

So the rule covers both halves: do not commit or push with a failing test, and do
not derive the number you report with a filter that drops the failing binaries.
The second half is the one that is easy to get wrong again, because
`cargo test -q` is quiet, the grep looks like a reasonable way to total up, and
the total is the only part anyone reads.

Also records the mistake as a mechanism rather than a symptom. Same defect as
rules 3 and 3a, one level up: a quantity that should have had one definition had
two, and nothing compared them.

Added a line to "Working rhythm" pointing at it, since that is the list someone
reads before committing.
2026-10-14 04:42:29 +02:00
ReaperOfVeriod 4bf552dab0 The alpha bug had a test named after the bug
Fixes a broken build left by 777a6a8, and the test it breaks is the interesting
part.

    texture::tests::alpha_is_scaled_from_eight_bit_halves
        assert_eq!(scale_alpha(0x7f), 0xfe);

That is not a test that happened to pin the wrong value. The test's NAME asserts
the wrong rule -- "scaled from eight bit halves" is the `a * 2` implementation
written down as intent, with 0xFE as its proof. Someone looked at this, thought
"alpha is a half-scale byte, double it", and then locked the conclusion into both
the identifier and the assertion.

So the previous commit's claim that `a * 2` was an implementation detail was wrong
in a way that matters: it had a name, a doc comment and a test defending it. That
is not an accident that escaped review, it is a convention that was adopted.

Two things follow.

The rule is now /128 * 255, and the test says so in its name. It also pins 0x7E ->
0xFB, a value the doubling cannot produce and which the disc does contain.

And 777a6a8 was pushed with a failing test in it. I only caught it because the
count came back as 100 rather than 278 and the discrepancy was worth one more
command -- `cargo test -q` plus a grep for `^test result: ok` sums only the
binaries that passed, so a failure is invisible in the total unless you go and
look at the raw lines. I had been using that filtered sum to report "278 tests"
for several commits without knowing it could hide a red build.

That is worth more than the bug it found. The measurement I had been reporting as
evidence of health was itself an instance of the defect this whole stretch has been
about: a quantity that should have had one definition, expressed as a filtered sum,
agreeing with the wrong answer and nobody comparing it against anything.

278 passing with the disc present, clippy clean.
2026-10-14 04:41:19 +02:00
ReaperOfVeriod 777a6a8d47 Measure the 0x80 alpha convention on the disc instead of asserting it in prose
The convention has been carried by comments and an inline expression. This
measures it, across every decoded texture on all 27 levels.

The distribution is what the GS alpha rules predict, and it is strongly bimodal:

    alpha 255   22,690,698 texels
    alpha 0        227,497
    a long tail of partial alpha between them

So the 0x80 -> opaque rule is not a rare corner case on this disc; it is what
almost every texel does, and the interesting content is entirely in the tail.

The useful part is the tail. It contains 253 -- twice -- and odd values across
the upper half: 129, 131, 133 ... 251, 253. 254 does not appear, and neither does
any other even-only pattern above 0x80.

That is the discriminator. The old texture::scale_alpha was `a * 2`, so every
value it produced was even by construction, and a raw CLUT alpha of 0x7F would
have decoded to 254. The corrected rule gives 253. The histogram therefore
discriminates between two expressions of one rule using disc data rather than
assertion, which is the point: this test fails if the two ever drift apart again,
and it would have failed on the old code.

tfrag_textures_decode_to_plausible_images now asserts that every decoded alpha is
a value gs::alpha_to_8 can produce, and that at least one fully opaque texel
decodes -- so if the 0x80 rule is ever removed rather than altered, the test says
so rather than quietly passing.

Worth noting what this histogram is not. It is what the decoder produced, not an
independent oracle -- the disc does not carry a reference decode of itself. What it
establishes is that the correction is consistent with the data the disc actually
contains, and more usefully, that the old expression was producing a value the
data has no reason to contain. Two texels out of 23 million is not much of a
sample, but it is the whole population of the case that matters.

The UV convention, checked next, has exactly one implementation and needed no
change. Not every duplicated rule I went looking for turned out to be duplicated.
2026-10-14 04:38:02 +02:00
ReaperOfVeriod 82d7c4f8a7 Three implementations of the 0x80 alpha rule; the one decoding pixels was wrong
Found the PS2 alpha convention expressed three times in three files, and the three
disagree:

    value    texture::scale_alpha    gs::alpha_to_8    gc-engine (float)
    0x40           128                  128              0.5
    0x7E           252                  251              250.9
    0x7F           254                  253              253.0
    0x80           255                  255              1.0

scale_alpha was `if a < 0x80 { a * 2 } else { 0xff }`. That is a/128 * 256, not
a/128 * 255 -- one too high at the top of the range, so 0x7F decoded to 254 where
the renderer's own alpha path gives 253. It is the only one of the three that was
wrong, and it was the one feeding decoded texture pixels.

scale_alpha now delegates to gs::alpha_to_8. The renderer keeps its float form
because it wants a float, and gc-formats has no business importing an engine
concern; two of the three are now one function.

The visual cost of this bug was at most 1/255 on a single palette entry. Nothing
would ever have shown it in a screenshot. That is the point rather than the
excuse: this class of error is not "big wrong", it is "right to within a rounding
step", in the code path nobody instruments. Every one of them is invisible by
construction, which is why the only defence is noticing that one rule has three
homes and making it have one.

Worth counting what this has turned into over three commits. The vtbl terminator:
two loops reading the same table with opposite answers. The sampler: a comment
asserting CLAMP over code doing Repeat. The alpha rule: three implementations,
one wrong. None of these is a subtle algorithm and none is in an exotic format.

All three are the same defect -- a quantity that should have one definition had
several, and nothing in the build made them compare.
2026-10-14 04:36:25 +02:00
ReaperOfVeriod 90ec3e6adf The texture sampler was Repeat while its comment claimed it clamped
Wiring the first gs rule into the renderer, and it turned up a bug that had been
sitting in plain sight:

    // Only mip level 0 is decoded, so minification must clamp rather than
    // invent a level the disc does not have here.
    ImageSamplerDescriptor {
        mag_filter: ImageFilterMode::Linear,
        ..default()          // <-- address_mode is Repeat
    }

The comment describes clamping. The code was doing the opposite: `..default()`
supplies bevy's default address mode, which is ImageAddressMode::Repeat. Nothing
here ever set an address mode, so every texture was sampling with Repeat while a
comment three lines up claimed it was not.

That is the GS rule I wrote in 81ec178 saying out loud -- CLAMP, with 1.0 held at
the far edge -- and the renderer was doing the other thing. The seam shows up as a
patch running off its texture wrapping onto the opposite edge instead of repeating
the border texel, which is exactly the kind of thing that reads as "the texture
decoded wrong" and sends you looking in the format parser instead of at one line
of sampler setup.

Also set mipmap_filter to Nearest. Only level 0 is decoded, so there is no chain
to interpolate across and the filter cannot matter; Nearest is the honest setting
until levels exist, and the comment now says that gs::mip_level returns 0 for every
patch until they are decoded, rather than implying a selection is happening.

Worth noting how this was found. I went looking for where clamp01 should live and
found the answer was "somewhere that already claims to do it, and doesn't". A
comment asserting behaviour is not evidence of behaviour -- the comment here was
specific and plausible and wrong, which is worse than no comment, because it stops
anyone checking.

Two API corrections along the way, both mine: ImageSamplerDescriptor has no
`address_mode` field -- it is address_mode_u and address_mode_v separately -- and
the engine is on bevy 0.19 rather than raw wgpu, so the GS rules reach the GPU
through bevy's sampler. The roadmap now says so instead of implying a wgpu
integration that does not exist.

278 tests, clippy clean.
2026-10-14 04:34:52 +02:00
ReaperOfVeriod bbd8cdccab Phase 5 is Aranos, not Veldin -- Veldin is not on this disc
Caught by the user, and it was wrong in four places: the Phase 5 heading, its
gate, its go/no-go line, and the PCSX2 verification plan's "Level 0 (Veldin)".

Veldin is Ratchet & Clank's home planet in the 2002 game. Going Commando opens on
Aranos, and there is no Veldin anywhere in this disc. The error looks like it
carried over from the first game when the roadmap was drafted, which is an easy
slip to make and an unhelpful one to leave standing: a vertical slice defined as
"completable and matching PCSX2 frame-for-frame" is not something anyone could
have attempted or judged while pointing at the wrong planet.

Confirmed against the vendored decomp rather than taken on faith. Its
docs/LEVELS.md refers to level 00 as Aranos directly -- "corrects three measured
Aranos entries before fingerprinting ... These corrections apply to level 00 only"
-- so level 0 is Aranos in the reference material this project has been using all
along, and the roadmap disagreed with the decomp.

There is a real R&C1 reference nearby and it is worth keeping, because the
reasoning under the gate is sound and only the noun was wrong. Phase 5 argues that
spherical gravity is the thing most likely to break, because it invalidates the
movement and camera assumptions the engine inherits from Ratchet & Clank (2002).
That is about inherited code from the previous game, not about the level being
built, and the text now says so explicitly so the next reader does not have to
work out which game is meant.

No code change. Documentation only.
2026-10-14 04:29:57 +02:00
ReaperOfVeriod cc52060c67 Pin the dispatch counts exactly, after finding two of them were pinned wrong
Follows directly from 65025cb, whose conclusion was that these tests assert bounds
rather than numbers and so cannot detect the drift they were known to contain.
Applying that to the tables involved.

Three things turned up.

The counts are now pinned: vtbl 5,822 records / 1,162 ids, camvtbl 217 / 14,
sndvtbl 32 / 6. Every one of these was previously `records > 5_000`,
`records > 0`, or nothing at all.

Two of the bad counts had been PINNED, not merely missed. Two separate tests
carried `assert_eq!(classes.len(), 1163)`. So walking past the terminator was not
an oversight that slipped through review -- the wrong number had been written down,
deliberately, at some point when it was correct. Both now say 1,162.

That is worth pausing on, because it is a different failure from the one I
described last commit. I said the tests were too loose to catch drift. In fact two
of them were too tight: they pinned a measurement that was wrong, and so they
actively defended it. A threshold and a wrong constant fail in opposite ways, and
this suite had one of each on the same table.

The third is the moby handler count. That one stops on the terminator in the
*handler* word rather than the id word, landing 34 records short at 5,788. It is
measuring a different question -- records with a real geometry handler -- so it is
correct, and it now says so at the break rather than leaving the next reader to
wonder why it disagrees with the other three.

Tests are 278 as before; nothing here changed what is counted, only what is
asserted. That is the whole point -- the previous 278 and these 278 agree, and now
they cannot silently stop agreeing.
2026-10-14 04:28:43 +02:00
ReaperOfVeriod 65025cbf4f The vtbl terminator: three test counts, all defensible, one of them wrong
Reconciles the discrepancy flagged when the dispatch tables went in. Three
committed tests counted lvl.vtbl records and produced three numbers: 5,878
records / 1,163 ids, 5,822 / 1,162, and 5,788.

    5,822 records / 1,162 ids   the table
    5,788                       records that have a real handler
    5,878                       wrong

The record array terminates on id == 0xffffffff in the first word of a 3-word
record. Two of the tests reached that word with `if c != MAX { ... }` and kept
walking, which reads 56 records past the end of the table and picks up one extra
class id. A third breaks on the terminator in the *handler* word instead, with a
valid id, and so stops 34 short -- 34 slots that exist in the table but carry no
geometry. Both stopping points are correct for the question each test asks.

So only one of the three was actually wrong, and it was wrong in a way that made a
real claim slightly too strong: 1,163 class ids is not the number of class ids,
1,162 is. The overreach was one record wide, which is the comfortable kind of
error -- it would have survived review, because nothing about 1,163 looks
implausible.

Worth being plain about why this sat unresolved for so long. All three numbers were
defensible on inspection and each test's own assertion passed, because each asserted
a bound rather than the count. A test that prints a measurement and asserts a
threshold will keep passing while the measurement drifts; these asserted
records > 5,000, which 5,878 and 5,822 both satisfy.

Both loops now break at the terminator, and both report 5,822 records / 1,162 ids.
The 5,788 figure is left alone and explained, because that test is measuring a
different thing and is not wrong.
2026-10-14 04:19:01 +02:00
ReaperOfVeriod 0b660cb0ce The extracted alpha rule disagreed with the renderer; the renderer was right
Pulling the 0x80 convention out of the renderer and into gc_formats::gs -- the
side benefit I claimed last commit rather than took -- immediately produced a
contradiction, which is the argument for having done it.

crates/gc-engine/src/level.rs already read it inline:

    let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 };

My first alpha_to_8 scaled by 127 instead of 128, which made 0x7F fully opaque
where the renderer had it at 253/255. One of them was wrong and it was not the new
one: the disc convention is a 0..0x80 range with 0x80 as the top, so 0x7F is
127/128, not 127/127.

Corrected to match, and the doc now records why -- including that the renderer was
already right, so this is a correction to code I wrote two commits ago rather than
to anyone else's.

One genuine ambiguity remains and it is stated rather than papered over: 0x40 is
exactly the midpoint, and the renderer works in floats where it is exactly 0.5.
Widening to an integer has to round, and this rounds half away from zero giving
128. There is no way to check that against the disc -- it needs a frame, which is
the tfrag morph item's unblock condition -- so it is documented as a choice.

The engine's call site keeps its inline form and now says why: it wants a float,
and the rule has one live site. Converting it would have meant changing working
code for no benefit, which is the opposite of what extracting the rule was for.

Worth noting what actually happened here. Extracting a rule to remove a magic
constant found that two expressions of it disagreed. Neither was obviously wrong
from its own code -- mine looked cleaner, and was not. That is a better argument
for centralising a rule than any amount of saying so.
2026-10-14 04:15:11 +02:00
ReaperOfVeriod 31f4ba3f7c GS alpha test and blend: all four rules the item names are now present
Finishes the rule layer. Two traps, both of which produce a picture that looks
slightly wrong rather than obviously broken:

AFAIL READS BACKWARDS. The PS2's alpha-test register holds an enable, a 7-bit
reference, and a bit that looks like "fail if alpha is above the reference". On
the hardware it means the opposite -- AFAIL = 0 discards where alpha is BELOW the
reference. passes_alpha_test takes the side explicitly rather than encoding the
register, and both directions are tested so the convention is pinned in code
rather than in a comment.

THE OPAQUE BIT MUST STAY OUT OF THE COMPARISON. 0x80 is fully opaque, but it is
also numerically 128, so a test that compares the raw byte against a 0x40
reference would have every fully-opaque fragment pass every threshold, and nothing
would ever be discarded. passes_alpha_test clamps the opaque bit off first, and
there is a test that asserts exactly that -- the failure mode here is a scene that
renders correctly with the wrong shading, which is not something anyone notices
by looking.

Blend is the four GS primitive types, decoded from the primitive-type byte and
named by what they do rather than by the documentation's IIIB/IIIA/IIIC labels,
which name two different modes identically. Out-of-range types decode to Off
rather than panicking, because the byte comes from asset data. Addition clamps at
255 rather than rescaling, matching the GS's 7-bit clamp; a caller wanting a true
average blends before widening, and the doc says so.

That completes the four rules the roadmap names for this item:
  alpha 0x80 == 1.0      alpha_to_8
  CLAMP wrap             clamp01
  mip selection          mip_level
  blend + alpha test     BlendMode, passes_alpha_test

Nine unit tests, no wgpu work, and the roadmap item stays [~] rather than [x] --
the rules are the input to the renderer, not the renderer.

Worth noting the pattern in both traps: each one produces output that is plausible
and slightly-off, with no error anywhere. That is the same shape as the sign
inversion in mip_level and the byte-order bug in the anchor search. The tests are
the only thing that catches this class, which is the argument for having them even
though nothing here needs a GPU.
2026-10-14 04:13:19 +02:00
ReaperOfVeriod 81ec17857a GC starts: the GS's own rules, as a module with tests
Picked Phase 3's GS item and did the part that is checkable without a GPU -- the
rule layer the renderer will have to feed wgpu. New gc_formats::gs with three of
the four rules the roadmap names, each unit-tested.

alpha_to_8: 0x80 IS OPAQUE. The obvious implementation of this is
  let v = value & 0x7F;
and it is wrong, because 0x80 & 0x7F is 0 -- which is precisely the mechanism
that makes a 7-bit alpha read as "half" to anything treating the byte as 8-bit
unsigned. The first version of this function did exactly that and the test caught
it. Below 0x80 the value is genuinely 7-bit and scales onto 1..255, so 0x7F is
opaque too and no non-zero alpha vanishes.

clamp01: CLAMP wrap, with 1.0 kept AT the far edge rather than wrapping to 0.
Under repeat sampling an exact 1.0 is a discontinuity; under clamp it is the
edge texel, and the game loads its textures that way.

mip_level: round(log2(image / on_screen)) clamped to the chain. The ratio is
image-over-screen and not the reverse -- a SMALLER on-screen size selects a
HIGHER mip level. I had this inverted in the first version, which made
mip_level(1.0, 64.0, 8) return 0 where it must return 6, and the test caught
that too. Both bugs were the same kind: a plausible implementation of a rule
whose sign convention is not obvious, and the only thing standing between them
and a commit was the test.

Not started: wgpu itself, and blend/alpha-test behaviour. This is the rule layer
only, and the roadmap item stays [~] rather than being ticked, because three of
four rules and zero of the GPU work is not "done".

Two of the four rules the item names were already half-present in the renderer --
moby alpha and the 1/4096 UVs -- so making them explicit here rather than scattered
is worth something on its own: 0x80's meaning was previously implicit in however
each call site happened to read the byte.
2026-10-14 04:12:10 +02:00
ReaperOfVeriod 3bc6c2d155 Phase 3 gate, second half: cross-check our masking against an independent hash
The gate asks for the relocated-copy result to be cross-checked against an
independent cluster hash. OpenRAC/rac2-gc-decomp's config/levels/functions.tsv is
exactly that: 7,765 functions carrying a content fingerprint and the levels each
appears on. 3,452 appear on all 27, in 2,984 distinct fingerprints.

Cross-checked against our own masking of the level-0 copy of each:

    our masked groups                      3,389
    reference fingerprint groups           2,984
    pairs the reference merges, we split   3,231
    pairs we merge, the reference splits       0

ZERO is the number that matters. Whenever our masking says two functions are the
same code, the independent fingerprint agrees -- our method never merges what
theirs separates. It is strictly finer: 3,389 groups against 2,984, splitting
3,231 pairs the reference keeps together.

The likely reason ours splits is that the reference fingerprint evidently ignores
something our mask preserves, so functions it calls identical differ in the words
our mask keeps. Coarseness, not contradiction. That is the compatible reading and
it is the one the 0 supports; had it been non-zero, this would have been a
conflict and worth chasing.

THE COMPARISON WAS INVALID BEFORE IT WAS VALID

The first run reported near-unanimous disagreement -- 1,900 of 3,392 pairs
differing -- which read as a significant finding. It was a category error in the
harness: key() masks the FIRST FOUR WORDS, while the reference fingerprints the
WHOLE function. A four-word summary compared against a whole-body hash is not a
comparison of two clusterings, and it produced confident numbers either way.

Recording it because nothing in the invalid output distinguishes it from a real
conflict, and the correction is only visible from the harness, not the numbers.
That is the third time this session that a measurement error produced a result
that looked like a finding -- after the constant-zero validity test and the stride
sampling artifact -- and the common factor is comparing things whose denominators
or extents differ.

The gate's second half is met, with "agrees" meaning "never contradicts" and ours
the finer of the two partitions.
2026-10-14 04:10:50 +02:00
ReaperOfVeriod 56d8dee836 Close Phase 3's level table: 27 keys, and 21 is not one of them
Picked the first open Phase 3 item. "Extend the level table from 19 to 27" is
about what the KEYS are, not how many there are.

ReRAC, which this project follows architecturally, has rc_game::map::LEVELS = 19
-- Ratchet & Clank 1's count, and this repo has no equivalent yet. Reading every
container header's own id off the disc gives:

    0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 22 23 24 25 26 30

27 keys, and 21 is NOT one of them. The container files run LEVEL0.WAD through
LEVEL26.WAD, but LEVEL21.WAD reports id 30, so the id space is 0..20, 22..26, 30 --
one number was cut from the disc and reused by nothing. That matches what
LevelHeader::id already documents, now as the table itself.

THE REFERENCE AGREES, INDEPENDENTLY

Every level key appearing in OpenRAC/rac2-gc-decomp's config/levels/functions.tsv
placements column, across 7,765 function rows, is exactly that set with 21 absent:

    00..20, 22, 23, 24, 25, 26, 30

Ours comes from the container headers; theirs from where functions land across
overlays. Two unrelated routes to the same key set, which is the only reason to
trust a hand-typed table like this one.

Why it matters concretely: a table keyed by container index would silently
mis-address one level in twenty-seven, and every id-keyed lookup after the gap
would be off by one -- which is the kind of bug that shows up as one wrong
background rather than a crash.

MY OWN ARITHMETIC SLIPPED ONCE

The first run asserted the disc's ids against (0..=20).chain(22..=27).chain([30]),
which is 28 entries -- 22..=27 should be 22..=26. The test caught it against the
disc, which is the right way round. Worth noting that the reference key set I
extracted by hand earlier was correct and the Rust expression was not, so the hand
check and the code check disagreed in the right direction.

Asserted in level_table_has_twenty_seven_keys_with_twenty_one_absent, from the
disc rather than from a copied constant, so it fails if a container id moves.

269 tests, clippy clean.
2026-10-14 04:08:56 +02:00
ReaperOfVeriod 88bc9566f2 Narrow moby_anim: the class header has no pointer to anything external
Finished the unblock attempt with two cheap tests on the 187 level-0 class
headers, because "which field points to the animation" needed to be ruled in or out
properly rather than guessed at.

NO HEADER u32 EQUALS THE SPAN'S OWN TABLE OFFSET, on any class. So the varying
fields are not self-relative pointers to the part table.

THE LARGEST VALUE IN ANY HEADER u32 IS 3,332,678,933. At +0x0c the value is
16,711,748 across classes. That is packed data or an identifier, not a
displacement.

So the header's varying fields are a MIXTURE: some hold in-span offsets (+0x10
through +0x1c run 192 to 10,800, comfortably inside the spans) and some hold packed
values far too large to be offsets. There is no single "field that points to the
animation", because not all of them point anywhere.

That is a real narrowing even though it is a negative. If per-class animation data
exists on the disc it is INSIDE the class span, not at a separate pointer target.
So the next step is not "find the pointer" -- it is to subtract the geometry the
parser already accounts for from the span and look at what remains. That is a
bounded question with an existing control, rather than an open-ended one.

moby_anim is still not unblocked. It is now bounded rather than open.

The other three blocked items are unchanged and, as recorded, not reachable from
here without something this repository does not have: a debug build or an external
format description for collision, ground truth for volumes, and a captured frame at
a known camera distance for the tfrag morph.

Moving to Phase 3 next. Its first unchecked item is "extend the level table from 19
to 27" -- concrete, bounded, and independent of everything above.
2026-10-14 04:03:53 +02:00
ReaperOfVeriod 4776db199b Attempt the moby_anim unblock: a location and a failure, not a result
Asked whether the blocked items could be unblocked, and tried the one that looked
tractable. It did not work, and this records where and why rather than leaving the
attempt implicit.

VERDICT PER ITEM

  moby_anim    probably -- disc-side work. TRIED, stalled, see below
  tfrag morph  maybe -- would need the engine's own morph code
  volumes      no -- needs a count of level 0's trigger volumes from a map
  collision    no -- needs a debug build or an external format description

THE ATTEMPT

MobyInstance names animSeq, animLayers, jointCnt and jointCache. Those are RAM
pointers and useless on the disc, so the search moved to what they point AT,
reached from the moby class blob.

Level 0, 187 moby classes. The span opens with the part-table offset: 0x80 on 69
classes, 0x90 on 34, ranging 0 to 229,184 across the rest. So each class carries
roughly a 0x40-byte header before its parts, and that header is unexplored.

Per byte over +0x00..+0x40, how many of the 187 classes are non-zero:

    +0x00  177   the part-table offset
    +0x07  185   8 distinct values, 1..3
    +0x26  181   100 distinct -- varies per class
    +0x27  187   8 distinct
    +0x2d  187   8 distinct
    +0x30  170   120 distinct
    +0x3c  184   120 distinct
    +0x3e  187   110 distinct

Six or seven fields are constant across every class and about a dozen vary. None
is identifiable. The obvious discriminator -- find the field that points OUTSIDE
the span -- does not resolve, because every varying field holds values in the tens
of thousands, the same order as the span offsets themselves. They may be
intra-span pointers or they may be sizes, and nothing here separates them.

WHERE IT STALLS

Distinguishing them needs one of: the class loader in the overlay's .text, which
is now readable with the fixed disassembler, or a class whose animation is known
from outside the disc. Neither has been done, and the first is the obvious next
step rather than a dead end.

So moby_anim is NOT unblocked. What the attempt does establish is a location, a
field count to classify, and a specific reason the easy discriminator fails --
which is more than the item had, and considerably more than "still open".

The other three are unchanged and I do not think they are reachable from here.
Collision needs the assert, which is unreferenced in retail, or an external
description of R&C2's collision cell. Volumes needs ground truth nobody in this
repo can supply. Both are written up with those conditions attached.
2026-10-14 03:56:50 +02:00
ReaperOfVeriod b3b4d47d97 Search for the tie vertex stride: 64 where one exists, none for 62%
The 16-byte reading was retracted without a replacement. This searches for one --
every tie vertex region, strides 4, 8, ... up to len/16, keeping any stride where
some byte offset is constant across every unit:

    regions examined                1,844
    no stride found at all            1,139   (61.7%)
    stride 64                           491
    stride 56                           120
    stride 72                            49
    stride 48                            12
    stride 16                             3

64 where a stride is detectable, not 16. And for 1,139 regions there is no stride
in range producing a single constant byte -- those are genuinely variable, or the
element is larger than region/16, which is the same statement.

THE CONTROL THAT CHANGED THE ANSWER

The first run reported 1,561 regions at stride 64. Wrong, and wrong in a way worth
recording: a large stride divides a region into fewer units, and fewer units makes
an accidental constant more likely. Region length is fixed across candidates, so
the widest stride always has the fewest units and the best chance of constancy by
luck alone. The search was reporting its own sampling.

Requiring every candidate stride to cover the SAME minimum of 16 units, and
preferring the SMALLER stride on ties, drops it to 491 and promotes 56 and 72 into
view, which were invisible before.

This is rule 3a's sampling failure in its purest form -- an aggregate over
candidates with different denominators, where the largest denominator wins for a
reason unconnected to the data. The first number was not imprecise; it WAS the
artefact. Worth noting that without the control I would have reported "64" with
confidence and been right by accident, three times the observed figure.

What this does not settle: 64 is the stride where one is detectable, which is not
the same as the stride. The 1,139 regions with no constant byte at any tested
stride leave the element genuinely unknown. But 16 is now excluded quantitatively
-- 3 of 1,844 regions show a constant byte there -- which is the retraction's
content, as a measurement, and a third independent check now agreeing with it.
2026-10-14 03:54:32 +02:00
ReaperOfVeriod 7b6de36958 Mark the two tie tests that still measure the retracted 16-byte layout
The stride retraction (e5cd3df) disowned the 16-byte element reading and the
95,612-vertex population that came with it. Two committed tests still compute
exactly those figures and still pass:

    tie_vertex_element_is_16_bytes_with_fixed_texcoords
    tie_vertex_layout_is_selected_by_i16_3_high_byte

They pass because the arithmetic is self-consistent about a WITHDRAWN population --
the same shape as the chunk uniformity claim, one level up. The parser slices at
16 bytes, the test reads the slices, the numbers agree, and nothing in the loop can
notice the stride is wrong. A green test that cannot fail is the exact failure mode
this session has been cataloguing, and here it was left in place by someone else's
retraction rather than mine.

Both are KEPT and marked, for a specific reason: each is the record of what the
16-byte reading produces, so the retraction is visibly about the stride rather
than about the arithmetic. Deleting or renaming them would erase the thing the
retraction is a retraction OF, and there would be no way to check later that the
numbers moved because the layout changed rather than because someone edited a
constant.

The marking says, at the top of both:

    Do not quote the 82.5% figure as a property of tie vertices. It is a property
    of one reading of the vertex region, on a population the retraction disowned.

TWO DENOMINATORS, BOTH CORRECT, 92.9% APART

The layout test reports (4,5) as 12-bit fixed on 92.9% OF VERTICES. A separate
measurement on flat records found only 16 of 1,488 DISTINCT i16[4] values are
multiples of 4096.

Both are right. Most vertices use the handful of UV-corner values -- (0,0),
(0,4096), (4096,4096), (4096,0) -- which are all multiples of 4096, so the
per-vertex rate is high. The other 1,472 are not, so the per-distinct-value rate is
1%.

That gap IS the artifact, and naming both denominators is the most useful thing
available about it. A percentage over vertices and a percentage over distinct
values answer different questions, and only one of them was ever asked about --
which is the same base-rate error as the class-ID slot, where a bound that selected
for small numbers made nine values look like 305x enrichment.

268 tests.
2026-10-14 03:49:31 +02:00
ReaperOfVeriod 412ef4707b Withdraw the tie "not a joint index" claim: the measurement never settled it
Revisited an older conclusion and found the numbers were fine but the inference
from them was not.

The claim was: i16[3] in a tie vertex is not a skin/joint index, because vertices
sharing a value are 0.95x FARTHER apart than vertices differing in it.

    mean pairwise distance, same slot-3 value        14,481
    mean pairwise distance, different slot-3 value  13,795
    ratio                                              0.95x
    control: same position-x bucket                   8,105

The measurements are correct, and the control shows the test works. What does not
follow is the conclusion. The premise was "vertices sharing a joint sit together",
and that is only true for a TIGHTLY LOCALISED joint. It is false in the cases that
are common:

  * a rigid tie object -- a leaf, a banner, a piece of foliage -- puts every vertex
    on joint 0, so "vertices sharing a joint" IS the whole mesh and the mean
    distance is the mesh's own diameter
  * a root or hip bone's influence region can span most of a mesh
  * bone weights fall off gradually, so membership is not a spatial partition

A 0.95x ratio is precisely what a rigid object bound to one joint PREDICTS.
Absence of clustering is fully consistent with the joint reading; the test never
had the power to separate them. A negative result I did not expect was reported
as a disproof.

This makes the hypothesis STRONGER, for an independent reason: MobyInstance carries
jointCnt at 0x57 and jointCache at 0x58, so this engine demonstrably has joints and
per-moby joint state. A per-vertex field in 0..=254 is exactly the shape of a
joint index.

What was always established still stands, and is all that was: slot 3 is per vertex
(50 distinct values per packet) and not shared across LODs (75.3% same-class
against a 78.7% control). The LOD result is the one thing arguing against a joint
index, though two LODs binding a rigid mesh identically would produce it without
being informative.

So: not understood, with the joint reading now the LEADING CANDIDATE rather than a
disproved one. Corrected in the format file and the roadmap, including the second
mention added two commits ago.

This is the sixth correction in this session to withdraw a conclusion rather than
sharpen one, and the first where the measurement was sound and only the reading
was wrong -- which is the hardest kind to catch, because the number looks right and
is quoted in the commit message as well as the doc.
2026-10-14 03:48:17 +02:00
ReaperOfVeriod f182a4a0ac Transcribe the 256-byte MobyInstance; oClass closes the instance-to-routine loop
The decomp carries an authentic MobyInstance layout of 0x100 bytes with every
field named. It is now in gc_formats::moby with a 67-entry offset map, attributed
as a transcription rather than presented as our own finding.

Two fields matter:

    0x40  animSeq      animation sequence pointer
    0x44  animSeqT     animation time parameter
    0x48  animSpeed    playback speed
    0x50  animLayers   animation layer list
    0x57  jointCnt     joint count -- non-zero means the moby is skinned
    0x58  jointCache   joint matrix cache
    0xBC  oClass       object class id -- the dispatch key

OCLASS CLOSES A LOOP THAT HAS BEEN OPEN FOR SEVERAL COMMITS. It is the field the
runtime uses to reach the lvl.vtbl handler tables, so an instance and its routine
are now linked by a named offset, rather than by an assumption about which axis
the two tables share. That assumption -- the positional correspondence -- is still
refuted, and still does not need to be true.

jointCnt and jointCache settle something from earlier in this log BY CONTRADICTION
rather than confirmation. A tie vertex's i16[3] was tested as a skin/joint index
and disproved: vertices sharing it were 0.95x FARTHER apart than vertices differing
in it. That stands and is unaffected -- this is the moby struct, and a moby is a
different object from a tie. Recording the distinction rather than letting "joints"
appear in two places look like a confirmation of the earlier hypothesis.

WHAT THIS DOES NOT DO IS FIND moby_anim. It names the instance-side state, so the
asset to locate is whatever those pointers reference, and we still do not know
where that is. The item's unblock condition is now sharper -- it needs the data
behind those pointers, not the struct.

PROVENANCE, AND WHAT THE TEST CAN ACTUALLY CHECK

This is not verified against disc data and cannot be: moby instances are runtime
objects and nothing of that shape is in the container. The test asserts what can be
asserted without the bytes -- offsets ascend, do not overlap, the last field lands
exactly on 0x100, and o_class sits at 0xBC immediately before triggers.

Worth keeping distinct from the collision finding. That project's FUNCTION ADDRESSES
did not transfer: they landed in NOBITS memory. A struct's field order is a
different kind of claim from an address in a program's layout, so one failing does
not impeach the other -- but the distinction is real, and PROVENANCE.md now records
which is which rather than citing the project as a single authority.

268 tests.
2026-10-14 03:47:15 +02:00
ReaperOfVeriod 3785195ecd The dispatch tables hold 4,737 behaviours, not 5,822 records
Records and behaviours are different numbers, and the difference is one routine
serving several classes. Over all 27 levels:

    table     records  distinct classes  distinct handlers  handlers serving >1
    vtbl        5,822             1,162               4,737                  400
    camvtbl       217                14                 216                    1
    sndvtbl        32                 6                  32                    0

Sharing is real but modest, and it is almost entirely in the moby table: the
median handler serves one class and the busiest serves 34. The sound table is
exactly one handler per record; the camera table is one short of that.

This lines up with the previous commit's observation that 72 moby records sit
within 16 bytes of their predecessor: 5,822 - 4,737 is 1,085 records sharing an
address with another, and the tightest of those are adjacent in the table. Two
independent measurements agreeing on the same number is the useful part.

The practical figure is 4,737. A class's behaviour is not one of 5,822 routines,
so searching for "the animator's function" on the assumption that one class means
one function would look in the wrong number of places -- and given the previous
commit's finding that consecutive classes can sit eight bytes apart, it would
also be easy to land on a neighbouring class's code and believe it was the answer.

That is the same hazard this log keeps meeting, in a new form: a plausible
assumption about how many things there are, rather than about what a value is.

Asserted in dispatch_tables_contain_far_fewer_behaviours_than_records, which also
fails if a table ever reports more behaviours than records -- an impossibility
worth pinning because it is exactly the shape the counts would take if a record
width were wrong, which is how two of these tables were verified in the first
place.

265 tests.
2026-10-14 03:42:42 +02:00
ReaperOfVeriod 18af702420 Read a moby handler: the chain works, with two caveats and a 110 discrepancy
Followed the chain through -- dispatch table to handler address to disassembly --
and read level 0's class 2 handler at 0x00380a88:

    0x00380a84: jal   0x2ee0d0
    0x00380a88: .word 0x0240202d          <- VU1 microcode, not a failure
    0x00380a8c: lwc1  $f12, 0x40($s1)
    0x00380a98: addiu $a0, $s0, 0x20
    0x00380aa0: swc1  $f0, 0x18($s4)
    0x00380ab0: swc1  $f0, 0x14($s4)

Floats out of one structure, written back into another, with VU1 work alongside --
what a transform update should look like. The chain from an asset observation to the
game's own code is now complete and usable.

TWO CAVEATS, both learned by hitting them:

VU1 microcode interleaves with CPU code. 0x0240202d is a COP2 opcode, not an
undecodable word. A tool that counts .word lines as failures is counting the wrong
thing -- a trap I walked into earlier today with the region walkers, now recorded
so it is not repeated.

Handlers are not all independent functions. Level 0's class 2 is at 0x380a88 and
class 0x0E at 0x380a90 -- EIGHT BYTES apart -- and 72 of the 5,788 records sit
within 16 bytes of the one before them. Consecutive classes share one routine with
per-class entry points inside it, so reading "a handler" gives a fragment. That
matters downstream: a class's behaviour is not one function, and assuming
otherwise would misattribute shared code.

A DISCREPANCY WITH THE REFERENCE

The decomp reports 5,598 of 5,788 moby handlers (96.7%) preceded by a jr ra within
three words. Measured here, the same way, over the same 5,788 records: 5,488
(94.8%). Same population, same test, 110 handlers different.

Both figures support the premise, so nothing turns on it, and I am recording it
rather than picking the number that suits. The reference measured on 2026-10-02
against its own pinned overlay set, so a method or version difference is as
plausible as a data difference and there is no basis here for saying which. The
test asserts the premise (>90%) and deliberately NOT either exact figure.

Asserted in moby_handlers_are_overwhelmingly_function_entries.

264 tests.
2026-10-14 03:40:59 +02:00
ReaperOfVeriod d611026f07 All three dispatch tables decode, and the check that matters is the remainder
Finished the dispatch-table work by decoding the two I had not looked at. The
level program carries three, and each divides its section EXACTLY by its record
width:

    section  file         width  record
    vtbl     lvl.vtbl         3  oClass, UpdateMoby_<oClass>, auxiliary
    camvtbl  lvl.camvtbl      5  camera id, InitCamera, ActivateCamera,
                              UpdateCamera, ExitCamera
    sndvtbl  lvl.sndvtbl      2  sound id, UpdateSound_<id>

    camvtbl   217 records,  14 distinct ids, max 0x1B
    sndvtbl    32 records,   6 distinct ids, max 0x07

Zero remainder on all 27 levels for both. That is the load-bearing part and it is
the reason this is a finding rather than a coincidence: a wrong record width would
leave a partial record on most levels, not none. Section sizes run 40..52 words
for the camera tables and 0..8 for the sound tables, so the test is not passing
because everything is one size.

Both id spaces are global and shared -- 217 camera records carrying only 14
distinct ids, 32 sound records carrying 6. Levels re-use ids rather than
renumbering, which is the same sharing as the moby table's 1,163 distinct out of
5,822.

Why this is more than parsing. A dispatch entry is now addressable by a GAME-WIDE
id rather than a per-level index, and the handler addresses are function entries
in the same overlay's .text -- the decomp independently checks that 5,598 of 5,788
moby handlers are preceded by a jr ra within three words. With the disassembler
now working on that section, a handler can be read. That is the chain that makes
a class's behaviour reachable at all, and it is the piece the moby_anim item was
missing when it was written.

Asserted in camera_and_sound_dispatch_tables_decode_at_their_documented_widths.

263 tests.
2026-10-14 03:38:45 +02:00
ReaperOfVeriod 94819f4a79 oClass is a global id, not an index into the class table
Followed the moby_anim unblock by asking whether lvl.vtbl's oClass indexes
core_index's moby class table. It does not, and the margin is not close:

    lvl.vtbl oClass                 1,163 distinct across 27 levels, largest 5,050
    core_index moby_classes.count   136 .. 259 per level

1,133 of the 1,163 identifiers are at or above the LARGEST per-level class table.
Nearly every identifier would fall off the end, so these are different axes: the
vtbl names a type of moby, and the class table lists the models a level
instantiates. Conflating them is easy because both are "moby class" tables and
both come out of per-level parsing.

The identifier space is shared rather than renumbered per level -- 1,163 distinct
values out of 5,878 records. Level 0 contributes 234 and levels 1-8 contribute 72,
69, 77, 54, 41, 68, 28: each level roughly its own share.

WHICH IS WHERE I OVERSTATED IT TWICE.

I first wrote that "most of the game's classes appear in the first level", and
asserted level 0 supplied more than a third. The data says 234 of 1,163 is 20%.
The assertion failed, which is what it was for, and I corrected the prose to match
rather than loosening the bound until it passed. Then I moved the threshold to a
fifth, which 20.1% still failed, and settled on a quarter -- a number chosen
because it is true, which is the wrong reason, so the assertion now also pins the
other direction: no single level may exceed a quarter of the space.

The useful consequence is the one the moby_anim item was waiting for. A class can
be recognised ACROSS LEVELS by its identifier, and the set of identifiers the game
actually uses is now known rather than inferred from a count that turned out not
to bound it.

Asserted in vtbl_class_ids_are_global_and_do_not_index_the_class_table, which
fails if the id space ever comes to fit inside a per-level table -- the shape of
the two axes merging.

262 tests.
2026-10-14 03:36:03 +02:00
ReaperOfVeriod 199ce33dd8 The moby class-id space is known: lvl.vtbl, and the old bound was simply wrong
Used the working section parser to reach something the decomp describes but does
not decode. lvl.vtbl is section 3 of the level program, records of three words:

    [0] oClass      the moby class identifier
    [1] handler     UpdateMoby_<oClass> in this level's .text
    [2] auxiliary   the same value for every record of a level, 26 of 27

Across 27 levels: 5,878 records, 1,163 distinct class ids, range 0..0x13BA (5,050),
median 38 new ids per level. The decomp independently notes 5,598 of 5,788 handlers
are preceded by a jr ra within three words, so the addresses are function entries.

WHY THIS RETIRES THE EARLIER BOUND

moby_classes.count is 238 on level 0. 1,135 of the 1,163 real ids -- 97.6% -- are
at or above that number. So "value < moby_classes.count" was never a test for "this
is a class id". It was a test for "this is a small number".

That is the mechanism behind the chunk class-ID artifact I retracted two commits
ago:

    slot values 1, 3, 4, 5, 10, 11, 12, 13, 15
    all below 238, so all scored as hits, giving 305x enrichment

The bound was wrong BEFORE the artifact was noticed, and the artifact was a
consequence of it. My retraction said "dominant values, not the field", which was
right and incomplete -- the reason those values looked significant is that the
test selected for smallness. Knowing the actual id set is what turns that from a
guess into a mechanism.

RE-SCORED AGAINST THE REAL SET

With the game's own 1,163 ids as the validity test, nothing separates:

    best slot      0.375   (type f2ef4275 slot 34)
    chance rate    0.018   (1,163 ids in 65,536 values)
    a dozen slots  0.25 .. 0.38, no gap between them

Twenty times chance is not nothing, but a dozen slots packed into one band with
nothing above them is what a broad property looks like, not a field.

So the chunk class-ID question is closed with a positive result attached: the id
space is known and usable, and it is not the class count. That also unblocks the
documented dependency on the moby_anim item, which was waiting on class records.

Asserted in moby_class_ids_come_from_lvl_vtbl_not_from_the_class_count.

262 tests.
2026-10-14 03:34:35 +02:00
ReaperOfVeriod 9f4f4acecb The collision assert string is never referenced: the last route is closed
Followed the route I recorded as remaining -- find how the code addresses the
assert string at 0x001a8998 in .lit. It does not address it.

Level 0's program is 2,751,268 bytes. Searching it for both ways an address can
be materialised:

    address low half              0x8998    0 occurrences, word-aligned or not
    $gp-relative offset           0x99a8    0 occurrences, word-aligned or not
    exact 32-bit 0x001a8998                 0 occurrences

Validated by a positive control in the same run: the .lit load base's own low
half, 0x7c80, occurs exactly ONCE, word-aligned. A search that finds that will
find any real reference, so the zeros are real.

So no instruction and no data word in the retail level program references the
collision assert string -- not by lui/addiu, not by a literal pointer, not by a
table entry. The text is present in .lit at overlay+0xd18, and the same text also
appears in the boot binary at file offset 0xA9558, but nothing points at either.

The reading that fits: the assertion is compiled out in retail and the string is
dead data. Debug string pools routinely keep messages whose only user was an
#if-ed-out assert. The decomp's table lists this name because it recovered it
FROM THE STRING, not because it found code calling it -- which is worth noting,
because a name in a symbol table looks like evidence of a caller and is not one.

That closes the last route this file records for collision. Getting the cell
layout from the disc would need a build with assertions enabled, a debug listing,
or an independent description of R&C2's collision cell. None of those is on this
disc, and saying so is more useful than another scan.

Summary of collision, all of it now checked: the payload is verbatim in RAM, it
does not walk as fixed-stride cells, the disc-side segmentation is exhausted, the
string that would have led to the routine is unreferenced, and both of the
decomp's addresses are dead ends.
2026-10-14 03:25:32 +02:00
ReaperOfVeriod 66a96111d7 Kill the 48-byte loop lead: the function takes no pointer arguments
Finished the task I named last commit rather than leaving it as a lead.

Camera_CollPrimTest is declared Camera_CollPrimTest(void *prim, void *grid) --
two pointer arguments, which in MIPS arrive in $a0 and $a1.

The function's prologue is at 0x002db078, matching the +0x70 teardown at
0x002db354. The two instructions after it:

    0x002db078: addiu $sp, $sp, -0x70
    0x002db07c: lw    $v1, -0x3f4c($v0)    <- $v0, not an incoming argument
    0x002db080: addiu $a0, $zero, 0x6f     <- $a0 set to a constant

$v0 is caller-saved scratch, not an argument register, and $a0 is overwritten
with the constant 111 on the next instruction. No void* reaches this function, so
it is not Camera_CollPrimTest, whatever the assert table labels it.

It reads four consecutive words from boot memory -- 0x1a7348 through 0x1a7354 --
all below the level program base at 0x001A7C80, which is the boot's resident
region and identical in every program. A different data source from the collision
payload, and the 48-byte stride loop belongs to whatever that structure is.

So the lead is dead on evidence rather than on doubt, and both routes the decomp
appeared to offer are now closed:

    Camera_CollPrimTest  @ 0x001E7A50   lands in NOBITS, all zero
    GridOutOfBounds      @ 0x002db350   a function with no pointer arguments

Neither address reaches the collision parser. Worth noting how cheap the check was
once the tool worked: three instructions past a prologue is enough to disqualify a
function whose name asserts a signature. The expensive part was not the analysis,
it was the disassembler that stopped after five words and made everything look
empty.

The route that remains is the one already recorded -- find how the code addresses
the assert STRING at 0x001a8998 in .lit, which is not a $gp-relative load and is
not a symbol address in either table. Note that the string and the code are five
megabytes apart in different sections, so whatever reference exists is not a
simple local reference, and that is the thing to look for.
2026-10-14 03:18:02 +02:00
ReaperOfVeriod 2f5979e652 Update the decomp; read .text, and fix the disassembler that made it unreadable
Vendored rac2-gc-decomp is 13 commits behind, 12,375 lines added, almost all src/.
It names no collision data structure, so the format is still not handed over. Two
things are usable, and one precondition had to be fixed first.

THE PRECONDITION: the disassembler was stopping after five instructions.

tools/overlay-anchors called disasm_all across the whole window, which halts at
the first word capstone will not decode. In code regions that is almost
immediately, so every window came back with a handful of instructions and I had
concluded there was nothing there. It now walks four bytes at a time, prints
.word for anything undecodable, and reports how many failed. That is the whole
reason the earlier attempts produced nothing -- not that the code was absent.

WHAT THE DECOMP'S ASSERT TABLE ACTUALLY GIVES

assert-message-names.tsv maps each assert name to an address. For level 0,
CameraCollPrimTestGridOutOfBounds is 0x002db350. That address is not the assert
call and not the string. It is:

    0x002db350: jr              $ra
    0x002db354: addiu           $sp, $sp, 0x70

an epilogue. The address lands on the last group of the function before it, and
0x002db35c is a fresh prologue. It is also in .text (0x002a3b80..0x00447900),
five megabytes from the .lit string at 0x001a8998 -- which explains the "$gp search
returns zero" result from the other side. The string and the code are in different
sections, five megabytes apart.

WHAT IS ACTUALLY THERE

Immediately before the epilogue:

    0x002db2dc: lwc1   $f20, 0xc($s0)
    0x002db2ec: add.s   $f20, $f20, $f21
    0x002db2f0: c.olt.s $f0, $f20
    0x002db304: jal     0x2db4b8        <- the assert call
    0x002db314: lw      $v0, ($s1)      <- a count
    0x002db31c: slt     $v0, $s2, $v0
    0x002db320: bnez    $v0, 0x2db2cc   <- loop back
    0x002db324: addiu   $s0, $s0, 0x30  <- stride 48 bytes

A loop over 48-byte records, reading a float at +12 and accumulating, bounded by a
count, with f22 set to 1.0f just above it -- a minimum-height clamp.

NOT CLAIMED to be the collision grid. It is a strided float loop beside an assert
the decomp labels CameraCollPrimTestGridOutOfBounds. A plausible address near a
plausible assert is a lead, not an identification, and that is the exact gap this
log keeps tripping over. Confirming it means tracing the function's arguments back
to the collision payload and showing the grid's leading u16s -- level 0 is 11 x 33
-- drive the count at ($s1). Not done, and not asserted.

The undecodable words are VU1 microcode (COP2 opcodes), which is expected: the
function interleaves VIF-fed VU1 work with FPU arithmetic, like tfrag's.

Tools build, clippy clean, 260 tests.
2026-10-14 03:16:17 +02:00