fix(pipeline): MP1b review - pak format/reader hardening

Adversarially-verified review findings 2,3,4,5,6,9:

(2) PakFormat.CurrentFormatVersion=1 constant; PakWriter stamps it
unconditionally (caller header templates can no longer produce a
version-0 pak — the default-0 footgun); PakReader refuses any other
version with a message naming found/expected. Tests: versions 0 and 2
both rejected; writer stamps even when the template omits the field.

(3) Reader robustness, all under the documented corrupt-=-missing
CONTRACT (external-file input — surface loudly once, then behave as
absent; never garbage, never throw from a lookup): (a) per-TOC-entry
bounds validation at open (offset/length outside [header, toc) or
past EOF -> logged once, entry missing, siblings unaffected); (b)
TryReadObjectMeshData catches deserialization failures (malformed
structure behind a matching CRC) -> logged once per entry, false; (c)
structurally unopenable files throw at OPEN with a clear message:
unfinalized header (tocOffset < header size — the placeholder-header
crash signature) and TOC-past-EOF truncation. Tests: corrupt TOC
entry, truncated pak, half-written pak, malformed-blob-behind-valid-
CRC (tamper + CRC recompute) — each verifying sibling blobs still read.

(4) Single-pass read: TryReadObjectMeshData now does ONE ReadArray out
of the map; CRC and deserialization run over the same buffer (was: a
separate VerifyCrc traversal + a second ReadArray + a ToArray copy
inside Serializer.Read). New Serializer.Read(byte[]) overload avoids
the defensive copy. Verdict set stays a ConcurrentDictionary (MP1c
calls this from 4 decode workers). True span-over-mmap zero-copy is
deferred to MP1c profiling per the class doc comment.

(5) PakWriter.Dispose restored to try/finally: Finish() does real I/O
and can throw (disk full) — the stream must ALWAYS close so no file
handle leaks mid-unwind. (Reverts the a5926ebc simplification, which
was wrong about this.) Test: dispose after an AddBlob exception leaves
the file deletable.

(6) CRC-32 known-answer vectors: "123456789" -> 0xCBF43926, empty ->
0x00000000, single zero byte -> 0xD202EF8D. The suite was previously
blind to a self-consistent-but-wrong CRC.

(9) Equality comparator floats switched to bit-equality
(SingleToInt32Bits/DoubleToInt64Bits, incl. Vector2/3 + Matrix4x4
components): for byte-identity round-trip purposes `==` was both too
strict (NaN==NaN false — a surviving NaN payload would wrongly FAIL)
and too lax (-0.0==+0.0 true — a sign-bit flip would wrongly PASS).

Content.Tests: 54/54 green (was 43).
This commit is contained in:
Erik 2026-07-05 22:08:59 +02:00
parent 8814a50f52
commit 84d1956d84
7 changed files with 416 additions and 57 deletions

View file

@ -62,7 +62,7 @@ public static class ObjectMeshDataEquality {
for (int i = 0; i < expected.Length; i++) {
AssertVector3Equal(expected[i].Position, actual[i].Position, $"{path}[{i}].Position");
AssertVector3Equal(expected[i].Normal, actual[i].Normal, $"{path}[{i}].Normal");
Assert.True(expected[i].UV == actual[i].UV, $"{path}[{i}].UV: expected {expected[i].UV}, got {actual[i].UV}");
Assert.True(BitEqual(expected[i].UV, actual[i].UV), $"{path}[{i}].UV: expected {expected[i].UV}, got {actual[i].UV}");
}
}
@ -133,31 +133,31 @@ public static class ObjectMeshDataEquality {
Assert.True(expected.ParticleType == actual.ParticleType, $"{path}.ParticleType: expected {expected.ParticleType}, got {actual.ParticleType}");
Assert.True(expected.GfxObjId.DataId == actual.GfxObjId.DataId, $"{path}.GfxObjId: expected 0x{expected.GfxObjId.DataId:X8}, got 0x{actual.GfxObjId.DataId:X8}");
Assert.True(expected.HwGfxObjId.DataId == actual.HwGfxObjId.DataId, $"{path}.HwGfxObjId: expected 0x{expected.HwGfxObjId.DataId:X8}, got 0x{actual.HwGfxObjId.DataId:X8}");
Assert.True(expected.Birthrate == actual.Birthrate, $"{path}.Birthrate: expected {expected.Birthrate}, got {actual.Birthrate}");
Assert.True(BitEqual(expected.Birthrate, actual.Birthrate), $"{path}.Birthrate: expected {expected.Birthrate}, got {actual.Birthrate}");
Assert.True(expected.MaxParticles == actual.MaxParticles, $"{path}.MaxParticles: expected {expected.MaxParticles}, got {actual.MaxParticles}");
Assert.True(expected.InitialParticles == actual.InitialParticles, $"{path}.InitialParticles: expected {expected.InitialParticles}, got {actual.InitialParticles}");
Assert.True(expected.TotalParticles == actual.TotalParticles, $"{path}.TotalParticles: expected {expected.TotalParticles}, got {actual.TotalParticles}");
Assert.True(expected.TotalSeconds == actual.TotalSeconds, $"{path}.TotalSeconds: expected {expected.TotalSeconds}, got {actual.TotalSeconds}");
Assert.True(expected.Lifespan == actual.Lifespan, $"{path}.Lifespan: expected {expected.Lifespan}, got {actual.Lifespan}");
Assert.True(expected.LifespanRand == actual.LifespanRand, $"{path}.LifespanRand: expected {expected.LifespanRand}, got {actual.LifespanRand}");
Assert.True(BitEqual(expected.TotalSeconds, actual.TotalSeconds), $"{path}.TotalSeconds: expected {expected.TotalSeconds}, got {actual.TotalSeconds}");
Assert.True(BitEqual(expected.Lifespan, actual.Lifespan), $"{path}.Lifespan: expected {expected.Lifespan}, got {actual.Lifespan}");
Assert.True(BitEqual(expected.LifespanRand, actual.LifespanRand), $"{path}.LifespanRand: expected {expected.LifespanRand}, got {actual.LifespanRand}");
AssertVector3Equal(expected.OffsetDir, actual.OffsetDir, $"{path}.OffsetDir");
Assert.True(expected.MinOffset == actual.MinOffset, $"{path}.MinOffset: expected {expected.MinOffset}, got {actual.MinOffset}");
Assert.True(expected.MaxOffset == actual.MaxOffset, $"{path}.MaxOffset: expected {expected.MaxOffset}, got {actual.MaxOffset}");
Assert.True(BitEqual(expected.MinOffset, actual.MinOffset), $"{path}.MinOffset: expected {expected.MinOffset}, got {actual.MinOffset}");
Assert.True(BitEqual(expected.MaxOffset, actual.MaxOffset), $"{path}.MaxOffset: expected {expected.MaxOffset}, got {actual.MaxOffset}");
AssertVector3Equal(expected.A, actual.A, $"{path}.A");
Assert.True(expected.MinA == actual.MinA, $"{path}.MinA: expected {expected.MinA}, got {actual.MinA}");
Assert.True(expected.MaxA == actual.MaxA, $"{path}.MaxA: expected {expected.MaxA}, got {actual.MaxA}");
Assert.True(BitEqual(expected.MinA, actual.MinA), $"{path}.MinA: expected {expected.MinA}, got {actual.MinA}");
Assert.True(BitEqual(expected.MaxA, actual.MaxA), $"{path}.MaxA: expected {expected.MaxA}, got {actual.MaxA}");
AssertVector3Equal(expected.B, actual.B, $"{path}.B");
Assert.True(expected.MinB == actual.MinB, $"{path}.MinB: expected {expected.MinB}, got {actual.MinB}");
Assert.True(expected.MaxB == actual.MaxB, $"{path}.MaxB: expected {expected.MaxB}, got {actual.MaxB}");
Assert.True(BitEqual(expected.MinB, actual.MinB), $"{path}.MinB: expected {expected.MinB}, got {actual.MinB}");
Assert.True(BitEqual(expected.MaxB, actual.MaxB), $"{path}.MaxB: expected {expected.MaxB}, got {actual.MaxB}");
AssertVector3Equal(expected.C, actual.C, $"{path}.C");
Assert.True(expected.MinC == actual.MinC, $"{path}.MinC: expected {expected.MinC}, got {actual.MinC}");
Assert.True(expected.MaxC == actual.MaxC, $"{path}.MaxC: expected {expected.MaxC}, got {actual.MaxC}");
Assert.True(expected.StartScale == actual.StartScale, $"{path}.StartScale: expected {expected.StartScale}, got {actual.StartScale}");
Assert.True(expected.FinalScale == actual.FinalScale, $"{path}.FinalScale: expected {expected.FinalScale}, got {actual.FinalScale}");
Assert.True(expected.ScaleRand == actual.ScaleRand, $"{path}.ScaleRand: expected {expected.ScaleRand}, got {actual.ScaleRand}");
Assert.True(expected.StartTrans == actual.StartTrans, $"{path}.StartTrans: expected {expected.StartTrans}, got {actual.StartTrans}");
Assert.True(expected.FinalTrans == actual.FinalTrans, $"{path}.FinalTrans: expected {expected.FinalTrans}, got {actual.FinalTrans}");
Assert.True(expected.TransRand == actual.TransRand, $"{path}.TransRand: expected {expected.TransRand}, got {actual.TransRand}");
Assert.True(BitEqual(expected.MinC, actual.MinC), $"{path}.MinC: expected {expected.MinC}, got {actual.MinC}");
Assert.True(BitEqual(expected.MaxC, actual.MaxC), $"{path}.MaxC: expected {expected.MaxC}, got {actual.MaxC}");
Assert.True(BitEqual(expected.StartScale, actual.StartScale), $"{path}.StartScale: expected {expected.StartScale}, got {actual.StartScale}");
Assert.True(BitEqual(expected.FinalScale, actual.FinalScale), $"{path}.FinalScale: expected {expected.FinalScale}, got {actual.FinalScale}");
Assert.True(BitEqual(expected.ScaleRand, actual.ScaleRand), $"{path}.ScaleRand: expected {expected.ScaleRand}, got {actual.ScaleRand}");
Assert.True(BitEqual(expected.StartTrans, actual.StartTrans), $"{path}.StartTrans: expected {expected.StartTrans}, got {actual.StartTrans}");
Assert.True(BitEqual(expected.FinalTrans, actual.FinalTrans), $"{path}.FinalTrans: expected {expected.FinalTrans}, got {actual.FinalTrans}");
Assert.True(BitEqual(expected.TransRand, actual.TransRand), $"{path}.TransRand: expected {expected.TransRand}, got {actual.TransRand}");
Assert.True(expected.IsParentLocal == actual.IsParentLocal, $"{path}.IsParentLocal: expected {expected.IsParentLocal}, got {actual.IsParentLocal}");
}
@ -177,11 +177,11 @@ public static class ObjectMeshDataEquality {
Assert.True(expected is not null, $"{path}: expected null but actual was non-null");
Assert.True(actual is not null, $"{path}: expected non-null but actual was null");
AssertVector3Equal(expected.Origin, actual.Origin, $"{path}.Origin");
Assert.True(expected.Radius == actual.Radius, $"{path}.Radius: expected {expected.Radius}, got {actual.Radius}");
Assert.True(BitEqual(expected.Radius, actual.Radius), $"{path}.Radius: expected {expected.Radius}, got {actual.Radius}");
}
private static void AssertVector3Equal(System.Numerics.Vector3 expected, System.Numerics.Vector3 actual, string path) {
Assert.True(expected == actual, $"{path}: expected {expected}, got {actual}");
Assert.True(BitEqual(expected, actual), $"{path}: expected {expected}, got {actual}");
}
private static void AssertVector3ArrayEqual(System.Numerics.Vector3[] expected, System.Numerics.Vector3[] actual, string path) {
@ -191,6 +191,31 @@ public static class ObjectMeshDataEquality {
}
private static void AssertMatrixEqual(System.Numerics.Matrix4x4 expected, System.Numerics.Matrix4x4 actual, string path) {
Assert.True(expected == actual, $"{path}: expected {expected}, got {actual}");
Assert.True(BitEqual(expected, actual), $"{path}: expected {expected}, got {actual}");
}
// ---- bitwise float equality ----------------------------------------------
// The pak stores raw IEEE-754 bits, so "round-trip preserved the field"
// means BIT equality, not `==` semantics: `==` is false for NaN==NaN
// (a NaN payload surviving the round-trip would FAIL a correct
// serializer) and true for -0.0==+0.0 (a sign-bit flip would silently
// PASS). Review finding 9.
private static bool BitEqual(float a, float b) =>
BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b);
private static bool BitEqual(double a, double b) =>
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
private static bool BitEqual(System.Numerics.Vector2 a, System.Numerics.Vector2 b) =>
BitEqual(a.X, b.X) && BitEqual(a.Y, b.Y);
private static bool BitEqual(System.Numerics.Vector3 a, System.Numerics.Vector3 b) =>
BitEqual(a.X, b.X) && BitEqual(a.Y, b.Y) && BitEqual(a.Z, b.Z);
private static bool BitEqual(System.Numerics.Matrix4x4 a, System.Numerics.Matrix4x4 b) =>
BitEqual(a.M11, b.M11) && BitEqual(a.M12, b.M12) && BitEqual(a.M13, b.M13) && BitEqual(a.M14, b.M14) &&
BitEqual(a.M21, b.M21) && BitEqual(a.M22, b.M22) && BitEqual(a.M23, b.M23) && BitEqual(a.M24, b.M24) &&
BitEqual(a.M31, b.M31) && BitEqual(a.M32, b.M32) && BitEqual(a.M33, b.M33) && BitEqual(a.M34, b.M34) &&
BitEqual(a.M41, b.M41) && BitEqual(a.M42, b.M42) && BitEqual(a.M43, b.M43) && BitEqual(a.M44, b.M44);
}