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.
This commit is contained in:
2026-10-14 04:51:43 +02:00
parent f0bc1fbdf4
commit 9bb04cca7b
3 changed files with 78 additions and 5 deletions
+28 -4
View File
@@ -252,15 +252,33 @@ pub struct TieRecord {
pub control_count: u8,
/// `+0x07`: 1..5 on disc, never above `control_count`.
pub control_size: u8,
/// `+0x08`: 3..7 on disc, never above `vert_size`.
/// `+0x08`: **quadwords of control data before the vertices**, 3..8 on disc
/// (`3`: 5 records, `4`: 3,364, `5`: 4,571, `6`: 3,469, `7`: 1,632,
/// `8`: 1 of 15,219). Peak at 5.
///
/// **Not a vertex offset.** The name is the one thing about this record that
/// is wrong: the value is the *length* of the leading region, not where the
/// vertices start within some larger array. The vertex data begins at this
/// many quadwords into the packet. Renamed in intent, not in the field name,
/// because the on-disc order is fixed and `vert_ofs` is what every existing
/// test and doc says.
pub vert_ofs: u8,
/// `+0x09`: 4..60 on disc.
/// `+0x09`: quadwords of vertex data; 4..60 on disc.
pub vert_size: u8,
/// `+0x0a`: equals `vert_size` on every record measured.
pub color_ofs: u8,
/// `+0x0b`: zero on every record measured.
pub color_count: u8,
/// `+0x0c`: 7..67 on disc, never below `scissor_size`.
/// `+0x0c`: quadword offset of the scissor data — and **exactly
/// `vert_ofs + vert_size`, on 15,219 of 15,219 records across all 27
/// levels**, including every record whose `strip_count` is non-zero.
///
/// It carries no information the other two fields do not. It is the end of
/// the vertex region, stored twice. The universality is itself the useful
/// part: it is the check that showed the packet really does partition as
/// control / vertices / scissor, since a derived quantity only lands on the
/// same value everywhere if those three regions are laid out the way the
/// length formula already assumed.
pub scissor_ofs: u8,
/// `+0x0d`: 1..5 on disc.
pub scissor_size: u8,
@@ -329,6 +347,10 @@ impl TieRecord {
&& self.vert_ofs <= self.vert_size
&& self.scissor_size <= self.scissor_ofs
&& self.control_size <= self.control_count
// Universal on 15,219 of 15,219 records, so unlike the first four --
// which are the large-partition rule plus three smaller ones -- this
// one can be a hard check. See `scissor_ofs`.
&& u16::from(self.scissor_ofs) == u16::from(self.vert_ofs) + u16::from(self.vert_size)
}
/// Quadwords of packet data: `vert_ofs + vert_size + scissor_size`.
@@ -557,7 +579,7 @@ mod tests {
7, 2, // control_count, control_size
4, 0x20, // vert_ofs, vert_size
0x20, 0, // color_ofs (= vert_size), color_count
0x30, 0x20, // scissor_ofs, scissor_size
0x24, 0x20, // scissor_ofs (= vert_ofs + vert_size), scissor_size
0, 0, // strip_count, strip_vertex_count
]);
}
@@ -580,6 +602,8 @@ mod tests {
let second = TieRecord::parse(records[1]).unwrap();
assert_eq!(second.shader_count, 1);
assert_eq!(second.bfc_distance, 5);
// scissor_ofs is vert_ofs + vert_size: 4 + 0x20 = 0x24.
assert_eq!(second.scissor_ofs, 0x24);
assert!(second.invariants_hold());
}
+44
View File
@@ -1766,3 +1766,47 @@ than two independent tables that happen to agree.
named. The Moby class-ID enrichment from earlier stands separately: 9,493 hits
against 246.1 expected is real, and the class arrays are contiguous per type, but
that identifies *where classes are referenced*, not which chunk is `moby_anim`.
## The tie control region is not a tag stream (negative result)
Went looking for the GS's GIF packets, which the tie packet header calls AD-GIF
(`shader_count` at `+0x04`, and `bfc_distance == 5 * shader_count` at `+0x05`).
Tried the first `5 * shader_count` quadwords of each packet 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 came out uniform.** Over 13,042 records, 10,443 distinct values of word 0
and no GIF tag type above a count of 2. By rule 4 that is the control result:
the region is not a tag stream, and no further effort should go into decoding it
as one.
The first version of that probe was wrong and worth recording, because it reached
the *opposite* conclusion twice. It 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 -- produced the uniform answer, and "not a tag stream" was the right
conclusion for the wrong reason. Re-measuring quadword 0 alone on its own gives
the same uniformity, so the conclusion survives; the reasoning did not.
What the probe did establish is the **packet partition**:
q[0 .. vert_ofs) control 3..8 quadwords (peak 5)
q[vert_ofs .. +vert_size) vertices
q[vert_ofs+vert_size ..] scissor
and a new **universal invariant**: `scissor_ofs == vert_ofs + vert_size` on
**15,219 of 15,219** records across all 27 levels, including every record 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 as the packet-length formula assumed.
It also found `vert_ofs` mis-named. It is not a vertex offset; it is the *length*
of the leading control region. The vertex data begins at that many quadwords into
the packet. The field name is left alone because the on-disc order is fixed and
everything already written about this record says `vert_ofs`, but the doc now
says what the value is.
**Still not known:** what the control region actually contains. It is not
standard VIF or GIF DMA tags. It may be pre-merged GS register writes in a
different framing, or per-class state rather than per-primitive draw data.
+6 -1
View File
@@ -742,7 +742,12 @@ lookup, and it is the main thing inherited from ReRAC.
- [ ] VU0/VU1 skinning and lighting on the PS2 float model
- [ ] LOD/morph/cull per `TfragProc` and `TieProc`
- [ ] Occlusion culling, fog, shadows
- [ ] GIF/VIF packet stream interpretation
- [~] GIF/VIF packet stream interpretation. **The tie control region is not a VIF or GIF
tag stream** -- measured uniform on both framings across 13,042 records, so
the GS's draw packets are not stored there in standard DMA form. What the
probe did establish is the packet partition (control / vertices / scissor)
and a universal invariant, `scissor_ofs == vert_ofs + vert_size`, on 15,219
of 15,219 records. See `docs/plan/level-assets.md`.
**Gate:** CPU and GPU paths agree on every vertex of a reference level — the
same identity check ReRAC runs.