feat(content): S1 exact CellStruct surface-index construction, recipe 8
Campaign OVERHAUL S1 chunk A. Retail's D3DPolyRender::ConstructMesh @0x0059DFA0 is ported as one pure Core descriptor plus the Content extraction that consumes it: - side candidates come only from sides_type (0/1/2); NoPos/NoNeg mean UV-array absence only and never suppress a side (CPolygon::UnPack @0x00538650); - ST_DOUBLE's second copy is reversed with a negative normal; ST_BOTH's negative side has a negative normal and forward fan order (reverse is on the copy ordinal, not the side ordinal); - an absent UV-index array is UV index 0 (ConstructMesh @0x0059E691 xor ebx,ebx, arbitrated on the PDB-paired binary); copyVert @0x0059C080 zeroes coordinates only for a negative or out-of-range index or a vertex without UVs, never by clamping to slot 0; - the subset owner is the source surface-array index, emitted in ascending slot order with retail's per-slot mask (2 > 8 > 4 precedence, positive surface OR on signed stippling > 0); - built-EnvCell admission is (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 after surface resolution (DrawEnvCell @0x0059F170 -> DrawMesh @0x0059D4A0 arg4=1); untextured slots are constructed but not emitted; - cell batches carry SourceSurfaceIndex, RetailSurfaceMask, RawSurfaceType, IsCellShell, and fixed clockwise raster cull (RenderMeshSubset @0x0059CA10); authored sides_type is no longer stored as GPU cull. Prepared-mesh serializer gains the four fields; bake recipe 7 -> 8 with a FullRebuild migration; pak format stays 2 (pinned). Ordinary GfxObj extraction is unchanged. AP-234's register row and CellMesh unification land in chunk B. Core: 32 descriptor tests. Content: 170/170. Bake: 18/18. Launcher.Core: 365/365 (Lane!=Linux). Solution Release build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
14d8fe6478
commit
acf172469e
15 changed files with 1746 additions and 296 deletions
|
|
@ -176,6 +176,54 @@ public class ObjectMeshDataSerializerTests {
|
|||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OH2/S1 chunk-2: a cell-shell batch with non-default values for all
|
||||
/// four new fields (SourceSurfaceIndex, RetailSurfaceMask,
|
||||
/// RawSurfaceType, IsCellShell), proving the round-trip preserves them
|
||||
/// and not just their zero/-1 neutral defaults.
|
||||
/// </summary>
|
||||
private static ObjectMeshData WithCellShellSubset() {
|
||||
var data = EmptyObject();
|
||||
data.ObjectId = 0x0700_0001u;
|
||||
data.TextureBatches[(64, 64, TextureFormat.RGBA8)] = new List<TextureBatchData> {
|
||||
new() {
|
||||
Key = new TextureKey { SurfaceId = 0x08000BFFu, PaletteId = 0, Stippling = StipplingType.None, IsSolid = false },
|
||||
TextureData = new byte[] { 1, 2, 3, 4 },
|
||||
Indices = new List<ushort> { 0, 1, 2 },
|
||||
CullMode = CullMode.Clockwise,
|
||||
Translucency = TranslucencyKind.Opaque,
|
||||
IsTransparent = false,
|
||||
IsAdditive = false,
|
||||
HasWrappingUVs = false,
|
||||
SourceSurfaceIndex = 7,
|
||||
RetailSurfaceMask = 0b0000_1101,
|
||||
RawSurfaceType = 0x11u,
|
||||
IsCellShell = true,
|
||||
},
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An ordinary (non-cell) GfxObj-shaped batch — the four new fields are
|
||||
/// left entirely unset, exercising their class-level neutral defaults
|
||||
/// (SourceSurfaceIndex = -1, RetailSurfaceMask = 0, RawSurfaceType = 0,
|
||||
/// IsCellShell = false) through the same round-trip path.
|
||||
/// </summary>
|
||||
private static ObjectMeshData WithGfxObjNeutralDefaults() {
|
||||
var data = EmptyObject();
|
||||
data.ObjectId = 0x0700_0002u;
|
||||
data.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List<TextureBatchData> {
|
||||
new() {
|
||||
Key = new TextureKey { SurfaceId = 0x08000001u, PaletteId = 0, Stippling = StipplingType.Positive, IsSolid = true },
|
||||
TextureData = new byte[] { 9, 9, 9, 9 },
|
||||
Indices = new List<ushort> { 0, 1, 2 },
|
||||
CullMode = CullMode.None,
|
||||
},
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllFixtures() {
|
||||
yield return new object[] { EmptyObject() };
|
||||
yield return new object[] { VerticesAndIndicesOnly() };
|
||||
|
|
@ -186,6 +234,21 @@ public class ObjectMeshDataSerializerTests {
|
|||
yield return new object[] { WithNullableFieldsAbsent() };
|
||||
yield return new object[] { WithEdgeLines() };
|
||||
yield return new object[] { WithNestedEnvCellGeometry() };
|
||||
yield return new object[] { WithCellShellSubset() };
|
||||
yield return new object[] { WithGfxObjNeutralDefaults() };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithGfxObjNeutralDefaults_Fixture_HasClassLevelNeutralDefaults() {
|
||||
// Sanity check on the fixture itself (not the serializer): proves
|
||||
// the "neutral default" claim is a property of TextureBatchData's
|
||||
// own field initializers, not something MeshExtractor's GfxObj path
|
||||
// has to opt into.
|
||||
TextureBatchData batch = Assert.Single(Assert.Single(WithGfxObjNeutralDefaults().TextureBatches).Value);
|
||||
Assert.Equal(-1, batch.SourceSurfaceIndex);
|
||||
Assert.Equal((byte)0, batch.RetailSurfaceMask);
|
||||
Assert.Equal(0u, batch.RawSurfaceType);
|
||||
Assert.False(batch.IsCellShell);
|
||||
}
|
||||
|
||||
// ---- round-trip tests ----------------------------------------------------
|
||||
|
|
@ -286,6 +349,43 @@ public class ObjectMeshDataSerializerTests {
|
|||
Assert.True(iB < iC, $"markerB (width=1,height=100) must precede markerC (width=100,height=1): iB={iB} iC={iC}");
|
||||
}
|
||||
|
||||
// ---- truncated/corrupt new-field rejection (OH2/S1 chunk-2, contract §10.5) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
[InlineData(10)]
|
||||
[InlineData(20)]
|
||||
[InlineData(46)]
|
||||
[InlineData(60)]
|
||||
public void Read_TruncatedTail_ThrowsDeterministically(int bytesRemoved) {
|
||||
// WithCellShellSubset's one small batch is the object's only
|
||||
// populated collection, so the LAST ~55 bytes of its serialized
|
||||
// form span exactly: the four new OH2 fields (SourceSurfaceIndex
|
||||
// int32, RetailSurfaceMask byte, RawSurfaceType uint32, IsCellShell
|
||||
// bool -- 10 bytes) immediately followed by the fixed
|
||||
// BoundingBox+SortCenter+DIDDegrade+SelectionSphere-flag+EdgeLines-
|
||||
// count tail every ObjectMeshData writes (45 bytes for this
|
||||
// fixture). Sweeping removal depths across that whole span proves
|
||||
// every cut inside or around the new fields fails closed --
|
||||
// EndOfStreamException from the underlying BinaryReader, never a
|
||||
// silently substituted default or a misread of a later field as one
|
||||
// of the new ones.
|
||||
var data = WithCellShellSubset();
|
||||
using var full = new MemoryStream();
|
||||
ObjectMeshDataSerializer.Write(data, full);
|
||||
byte[] fullBytes = full.ToArray();
|
||||
|
||||
byte[] truncated = fullBytes[..^bytesRemoved];
|
||||
|
||||
Assert.Throws<EndOfStreamException>(() => ObjectMeshDataSerializer.Read(truncated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_EmptyStream_ThrowsDeterministically() {
|
||||
Assert.Throws<EndOfStreamException>(() => ObjectMeshDataSerializer.Read(Array.Empty<byte>()));
|
||||
}
|
||||
|
||||
private static int IndexOfSequence(byte[] haystack, byte[] needle) {
|
||||
for (int i = 0; i <= haystack.Length - needle.Length; i++) {
|
||||
bool match = true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue