acdream/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
Erik acf172469e 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>
2026-09-02 18:40:33 +02:00

32 KiB

OH2 — retail CellStruct surface/subset construction contract

Date: 2026-09-01 Campaign: OVERHAUL / OH2 Scope: the built-EnvCell path only: CEnvCell::UnPackD3DPolyRender::ConstructMeshRenderDeviceD3D::DrawEnvCellD3DPolyRender::DrawMesh(..., arg4 = 1) Status: evidence complete; implementation not started Retail oracle: Sept. 2013 EoR named client, with live Ghidra arbitration of every branch whose decompiler rendering could change the result

Executive result

Retail does not use NoPos or NoNeg to decide whether a polygon side exists. Those bits say that the corresponding UV-index array is absent. The side set comes only from CPolygon::sides_type:

sides_type Retail candidates, in construction order
ST_SINGLE (0) positive surface once, positive normal, ordinary fan winding
ST_DOUBLE (1) positive surface twice: first positive normal/ordinary winding, then negative normal/reversed winding
ST_BOTH (2) positive surface/positive UV/positive normal, then negative surface/negative UV/negative normal; the negative fan is not index-reversed

Every candidate is constructed, including a candidate whose Surface.Type is untextured. The built EnvCell draw later skips a subset unless Surface.Type & (BASE1_IMAGE | BASE1_CLIPMAP) is nonzero. That later surface test, not NoPos, is the exact retail visibility rule.

The material/subset owner is the surface-array index. Retail allocates one batch accumulator and one mask byte per surface slot, writes triangle attributes with that slot index, and emits the nonempty attribute ranges in ascending surface-index order. It does not make Stippling, texture format, or sides_type part of the subset identity.

This requires an acdream.pak bake-recipe bump from 7 to 8 and a full rebuild, but does not require a container-format bump. PakFormat can remain 2: the container framing and asset directory do not change, while the serialized prepared-mesh record and its recipe do. Both Content and Launcher recipe constants must move together, with a 7 → 8 full-rebuild migration.

The canonical cathedral/Facility Hub cells have no textured NoPos polygon, so the exact rule produces the same visible/untextured admission split there. OH2 closes a real global contract gap (AP-234), but must not be presented as a standalone fix for the cathedral's camera-dependent leaking geometry.

1. Evidence method and authority

The named retail corpus was inspected first, as required:

  • docs/research/named-retail/acclient_2013_pseudo_c.txt
  • docs/research/named-retail/acclient.h

The following functions were then decompiled independently through the live Ghidra MCP bridge on port 8081 (patchmem project). Ghidra was used as the mandatory branch-sense arbiter, not merely as a name lookup:

Function Address Named-corpus anchor Branch fact arbitrated in Ghidra
CEnvCell::UnPack 0x0052D470 pseudo-C 310850 built EnvCells call ConstructMesh(..., 3.0f, true, ...)
CPolygon::UnPack 0x00538650 322296 NoPos/NoNeg suppress UV-array unpack only; ST_DOUBLE aliases negative surface/UV to positive
copyVert 0x0059C080 424779 normal is multiplied by +1/-1; absent or invalid UV writes zero UV coordinates
D3DPolyRender::DrawMesh 0x0059D4A0 426048 built-cell arg4=1 cannot take the ordinary-object untextured fallthrough
D3DPolyRender::ConstructMesh 0x0059DFA0 426717 exact side loops, reverse-winding branch, surface mask, vertex-key lanes, and attribute ranges
RenderDeviceD3D::DrawEnvCell 0x0059F170 427885 built branch calls DrawMesh(..., 1); fallback is a different polygon-list path

This matters especially for ConstructMesh: the pseudo-C reuses variables named arg1 and arg4 after their original parameters are dead. Ghidra confirms that, in the emission loops, the first is the side ordinal and the second is the duplicate-copy ordinal. Reading them as the function arguments would invert the ST_BOTH winding conclusion.

The installed DAT audit was read-only. The client data set used here has:

DAT SHA-256
client_portal.dat DC6E500BA22E6B186DB7171E3F3345238B6444C85D798ADC85E550973B8D12E4
client_cell_1.dat 6DB0ABF00FBCEED62C3F1EE842EE7C1F423D732BED77A5B7C102EE89A52AB99E
client_highres.dat 503E0828D14F2F9CCBC31431E1055AC188464BF4B499DE37F4C3D5B2D9F3E727
client_local_English.dat E85C820280C88FAC7DF6C8043F5E24596E9C8774193AF4123D756546F78FB2BB

2. Retail source structures and flags

2.1 CPolygon

The verbatim retail header declares (acclient.h:31855):

Offset Field Meaning in this contract
0x00 CVertex **vertices resolved authored vertices
0x04 uint16 *vertex_ids source vertex identity and fan order
0x08 Vec2Dscreen **screen unrelated here
0x0C int16 poly_id authored polygon id
0x0E char num_pts fan vertex count
0x0F char stippling stipple bits plus UV-absence bits
0x10 int sides_type ST_SINGLE / ST_DOUBLE / ST_BOTH
0x14 char *pos_uv_indices null when NO_POS_UVS
0x18 char *neg_uv_indices independent only for ST_BOTH; null when NO_NEG_UVS
0x1C uint16 pos_surface index into the EnvCell's ordered surface array
0x1E uint16 neg_surface same; aliased to positive for ST_DOUBLE
0x20 Plane plane not a subset selector

The retail stride is 0x30 bytes; ConstructMesh advances the polygon cursor by that stride (pseudo-C:427191).

2.2 Exact enums

SidesType (acclient.h:7372):

  • ST_SINGLE = 0
  • ST_DOUBLE = 1
  • ST_BOTH = 2

StipplingType (acclient.h:7380):

  • NO_STIPPLING = 0
  • POSITIVE_STIPPLING = 1
  • NEGATIVE_STIPPLING = 2
  • BOTH_STIPPLING = 3
  • NO_POS_UVS = 4
  • NO_NEG_UVS = 8
  • NO_UVS = 0x14 (the retail value is unusual but verbatim)

The DRW enum name CullMode is misleading on Polygon.SidesType. It is the retail SidesType, not a GPU rasterizer cull mode.

Relevant SurfaceType bits (acclient.h:5820):

Bit Retail name
0x00000001 BASE1_SOLID
0x00000002 BASE1_IMAGE
0x00000004 BASE1_CLIPMAP
0x00000010 TRANSLUCENT
0x00000100 ALPHA
0x00000200 INVALPHA
0x00010000 ADDITIVE

2.3 CPolygon::UnPack proves the NoPos/NoNeg meaning

CPolygon::UnPack @0x00538650 reads polygon id, point count, stippling, sides_type, both surface indices, and every vertex id first (pseudo-C:322302-322345). It then:

  1. allocates and reads pos_uv_indices only when (stippling & 4) == 0 (322347-322367);
  2. allocates and reads neg_uv_indices only when sides_type == 2 and (stippling & 8) == 0 (322369-322387);
  3. for sides_type == 1, aliases neg_surface = pos_surface and neg_uv_indices = pos_uv_indices (322389+, independently confirmed in Ghidra).

No branch removes a face. The flags named NoPos/NoNeg in DRW are retail's NO_POS_UVS/NO_NEG_UVS.

3. Exact retail construction algorithm

3.1 Caller and inputs

CEnvCell::UnPack @0x0052D470 resolves the ordered EnvCell surface array and its CCellStruct, then calls (pseudo-C:311081-311085):

D3DPolyRender::ConstructMesh(
    num_surfaces,
    surfaces,
    &structure.vertex_array,
    structure.num_polygons,
    structure.polygons,
    3.0f,
    true,
    &constructed_mesh)

That establishes that the surface array and its index order are direct construction inputs. The 3.0f and true control detail-UV/FVF construction; they do not alter side selection.

3.2 Per-surface mask and batch allocation

ConstructMesh allocates:

  • one mask byte per surface in MeshBuffer::isStippledOrAlphaedMask;
  • one MeshBatchType per surface index in a scratch array.

MeshBuffer is the 0x24-byte record at acclient.h:31949; its mask pointer is offset 0x10. MeshBatchType is the five-uint32 record at acclient.h:59629:

FirstTriangleIndex, NumTriangles, CurTriangleIndex,
FirstVertexIndex, LastVertexIndex

The initial mask is derived solely from the corresponding surface's raw type (pseudo-C:426788-426818) and the branch precedence is exact:

if (type & 0x10300) != 0: mask = 2
else if (type & BASE1_CLIPMAP) != 0: mask = 8
else if (type & TRANSLUCENT) != 0: mask = 4
else: mask = 0

For each polygon, retail then ORs (stippling > 0 ? 1 : 0) into only the positive surface's mask (426864-426871). The instruction is signed-byte SETG, not an arbitrary raw-byte-nonzero test. It is deliberately broader than the low two stipple-side bits: every defined nonzero enum value is positive, so NO_POS_UVS and NO_NEG_UVS also set bit 0. Raw corrupt values 0x80..0xFF do not. This does not create another subset.

3.3 Triangle counts and surface ownership

For each valid fan, baseline triangles are num_pts - 2.

  • ST_SINGLE: one baseline count on the positive surface.
  • ST_DOUBLE: total geometry capacity doubles, and the positive surface's batch count receives the baseline twice.
  • ST_BOTH: total capacity doubles; positive and negative surface batches each receive one baseline.

The count loop is at pseudo-C:426821-426871, including the critical ST_DOUBLE second addition at 426857-426858. Every reference is an index into the same surface-indexed MeshBatchType array. There is no texture key, surface DID, stippling, or texture-format grouping in this ownership decision.

3.4 Side/copy branch table

The emission loops at pseudo-C:427047-427194, confirmed instruction-for- instruction in Ghidra, reduce to this table:

sides_type Side ordinal Copy ordinal Surface UV map Normal sign Fan index order
0 0 0 pos_surface positive +1 [0, t+1, t+2]
1 0 0 pos_surface positive +1 [0, t+1, t+2]
1 0 1 pos_surface positive -1 [t+2, t+1, 0]
2 0 0 pos_surface positive +1 [0, t+1, t+2]
2 1 0 neg_surface negative -1 [0, t+1, t+2]

The counterintuitive last row is binding. Retail reverses indices when the copy ordinal is nonzero (pseudo-C:427140-427145). ST_BOTH changes the side ordinal, not the copy ordinal, so its negative candidate receives a negative normal but retains the base fan order. Do not normalize it to the more intuitive reversed winding.

Unknown raw sides_type values follow the single-side shape in retail's branching, but the installed data and header define only 0/1/2. The port should retain the raw value for evidence and quarantine/report invalid source data rather than silently invent a fourth public semantic.

3.5 UV absence and vertex identity

Retail builds an index map around:

(normal-sign lane, UV index, authored vertex id)

More exactly, positive vertices address (uvIndex * numVertices) + vertexId. The inverted-normal lane adds the maximum-UV-span offset before that product (pseudo-C:426881-426924, 427129-427167). Therefore:

  • the same authored vertex and UV may be shared within one sign lane;
  • positive- and negative-normal copies cannot alias;
  • a null UV map uses UV index 0, rather than suppressing the candidate. Arbitrated on the PDB-paired binary 2026-09-02 (S1 chunk A2 review): ConstructMesh @0x0059E683-0x0059E693 loads the polygon's UV-index array pointer, and on null executes xor ebx,ebx; otherwise movsx ebx, byte ptr [edx+edi] (the element is a SIGNED char). ebx is the index copyVert consumes. So an absent array is exactly "vertex UV slot 0", not a zero coordinate;
  • copyVert @0x0059C080 writes zero UV coordinates only when that index is negative, when it is >= CVertex::num_uvs, or when the VERTEX has no UV array (edi[4] == 0) (pseudo-C:424797-424829; Ghidra confirms the three conditions). It never clamps an out-of-range index to slot 0;
  • copyVert multiplies the authored normal by the selected +1/-1 (424791-424793).

This is the exact reason NoPos/NoNeg cannot be treated as face-absence flags.

3.6 Attribute/subset ordering

After filling the mesh, retail scans MeshBatchType records from surface index zero upward (pseudo-C:427214-427276). For every record with a nonzero triangle count it appends one D3DX attribute range:

AttribId    = surfaceIndex
FaceStart   = FirstTriangleIndex
FaceCount   = NumTriangles
VertexStart = FirstVertexIndex
VertexCount = LastVertexIndex - FirstVertexIndex + 1

Consequences for the port:

  1. nonempty subset order is ascending source surface index;
  2. duplicate surface DIDs in distinct slots remain distinct material owners;
  3. different stippling values on the same surface slot aggregate into one subset and one final mask byte;
  4. polygon triangles within a surface retain source polygon order;
  5. texture atlas/format grouping may optimize storage, but must not become the semantic subset/order owner.

4. Exact built-EnvCell draw admission

RenderDeviceD3D::DrawEnvCell @0x0059F170 has two branches.

The built branch performs static-light color setup and calls D3DPolyRender::DrawMesh(num_surfaces, surfaces, constructed_mesh, 1) (pseudo-C:427902-427907). The fallback appends all structure polygons to the legacy polygon list (427910-427932). This OH2 contract targets the built branch used by prepared CellStruct meshes; the fallback is explicitly outside this slice.

D3DPolyRender::DrawMesh @0x0059D4A0 admits a subset immediately when:

skipNoTexture == 0 || (surface.Type & 6) != 0

(pseudo-C:426064). skipNoTexture is initialized to 1 in this client and no countervailing production write was found. When the type test fails, the only fallback requires both ObjBuildingOrBuildingPart == 0 and arg4 == 0 (426067-426075). DrawEnvCell supplies arg4 == 1, so its exact rule is:

draw built EnvCell subset iff
(Surface.Type & (BASE1_IMAGE | BASE1_CLIPMAP)) != 0

Thus:

  • BASE1_SOLID (0x1) is constructed but skipped;
  • BASE1_SOLID | TRANSLUCENT (0x11) is constructed but skipped;
  • BASE1_IMAGE (0x2) draws;
  • BASE1_CLIPMAP (0x4) draws;
  • NoPos/NoNeg has no role in draw admission;
  • ordinary GfxObj DrawMesh(..., arg4=0) has a different fallthrough and must not inherit the built-EnvCell filter by accident.

5. Current acdream behavior and exact differences

5.1 MeshExtractor.PrepareCellStructMeshData

Current production extraction begins at src/AcDream.Content/MeshExtractor.cs:733.

Contract point Current behavior Retail behavior Required change
Side existence hasPos = !NoPos, hasNeg = !NoNeg (:770-779) only sides_type selects candidates remove UV-bit gating; resolve candidates from the branch table
Surface draw test intentionally approximated before surface resolution (:757-769) resolve surface, then built-cell admit with (type & 6) != 0 move exact admission after surface resolution
ST_DOUBLE geometry positive twice, second inverted/reversed same retain, but drive through the common descriptor
ST_BOTH geometry positive then negative, inverted/no reverse same only when hasNeg retain exact winding; remove NoNeg suppression
UV absence candidate is dropped candidate exists; null UV means UV index/coordinate zero preserve candidate and apply zero-UV fallback
Subset key TextureKey includes surface DID, palette, full stippling, solid flag; then also raw SidesType (:973-990) source surface-array index only make surface slot/ordinal the semantic owner
Ordering dictionary grouped first by (width,height,format) ascending source surface index preserve a separate immutable semantic order
Duplicate slot equal DID/key/sides may merge distinct slots remain distinct subsets retain source surface index in batch record
Mask only derived presentation booleans survive exact initial/final mask per surface store raw type plus retail mask (or a lossless equivalent)
GPU cull raw SidesType is stored in TextureBatchData.CullMode geometry is expanded, then cell shells use fixed retail CW cull separate authored sides metadata from rasterizer state

The current fan and sign shape is close, but that does not make the current batch contract equivalent. TextureKey.Stippling splits polygons that retail aggregates, while a repeated surface DID can merge slots retail keeps apart.

src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs:421-434 already records the later raster rule correctly: Polygon.SidesType is not GPU culling, and expanded cell geometry uses fixed retail clockwise culling. OH2 must make the prepared record express this distinction instead of continuing to overload TextureBatchData.CullMode.

5.2 Divergent Core helper

src/AcDream.Core/Meshing/CellMesh.cs carries the same NoPos approximation and only emits a positive side. It cannot remain an independent production interpretation. The exact side descriptor must either be used there too, or that helper's render-production role must be deleted. Collision code may keep its own explicitly documented source needs; it must not silently borrow render-side draw admission.

src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs already has the correct surface fact: untextured means neither Base1Image nor Base1ClipMap. The defect is when and how CellStruct extraction applies it.

5.3 AP-234

docs/architecture/retail-divergence-register.md:260 records precisely this approximation. The implementation commit must retire AP-234 with the retail addresses and the new tests, rather than merely changing its prose.

6. Installed-DAT before-state facts

The existing read-only tools/A8CellAudit was run against the requested canonical cells. Its emitPos/skipped columns report the current NoPos proxy, not retail truth:

Cell Environment Struct Polygons Current positive proxy Current NoPos skips ST_DOUBLE polygons
0xF4180100 0x0D00049D 0 20 18 2 0
0xF4180101 0x0D00049D 1 45 43 2 0
0xF4180104 0x0D00049E 1 41 36 5 8
0xF4180106 0x0D00049F 0 79 76 3 0
0xF4180107 0x0D00049F 1 84 78 6 0
0xF4180112 0x0D0004A3 0 16 13 3 0
0xF4180113 0x0D0004A3 1 7 3 4 0
0xF4180114 0x0D0004A3 2 33 28 5 0
0x8A02015E 0x0D00003A 0 19 14 5 0
0x8A02015F 0x0D000044 0 9 7 2 0

For all canonical cells, every currently skipped NoPos polygon resolves to an untextured Surface.Type == 0x11 (BASE1_SOLID | TRANSLUCENT). Therefore the exact path constructs those candidates and then skips their subsets at the built-EnvCell draw gate; visible admission is unchanged.

0xF4180104 is an important geometry pin. Polygons 0x001C..0x0023 are ST_DOUBLE over surface slot 5, Surface DID 0x08000BFF, type BASE1_CLIPMAP (0x4). Retail emits 36 ordinary positive candidates plus 8 reversed negative-normal copies: 44 drawable side calls, not the audit's 36-positive-only headline.

Representative canonical surfaces:

  • textured image (0x2): 0x08000BFB, 0x08000ACC, 0x0800055C, 0x08000C00, 0x08000BFC, 0x08000377, 0x08000379;
  • clip map (0x4): 0x08000BFF;
  • untextured solid/translucent (0x11): 0x08000C0F, 0x08000C10, 0x080000DD, 0x08000034.

The audit's complete Environment CellStruct catalog reported:

environments=772 cellStructs=3168 polygons=38189 triangles=70091 degenerate=861
sides: ST_SINGLE=37843, ST_DOUBLE=346, ST_BOTH=0
orientation: aligned=55347 opposed=4537 ambiguous=9346 inconsistentReliableFans=2

No installed Environment CellStruct exercises ST_BOTH; its behavior is nevertheless fully implemented by retail and must be pinned synthetically.

An additional deterministic exploratory sample enumerated 734,976 candidate EnvCell ids, sorted the present ids, and inspected every 89th cell (8,259 cells):

polygons=97020 surfaces=585
ST_SINGLE=96922 ST_DOUBLE=98 ST_BOTH=0
NoPos + textured surface = 0
not-NoPos + untextured surface = 55

The 55 not-NoPos + untextured cases are concrete current false admissions. Examples include:

  • 0x00690114, environment 0x0D0000E4, struct 0, polygon 0, surface 0x080000DF, type 0x1;
  • 0x006A0109, environment 0x0D0000C8, struct 3, polygons 0x0..0xC, surface 0x080000DF, type 0x1;
  • 0x008D018B, environment 0x0D0000A0, struct 0, polygons 0/1, surface 0x080000DF;
  • 0x0090010D, environment 0x0D000044, polygons 0x0..0x6, surface 0x080000DF;
  • 0x6F9D010B, environment 0x0D000453, struct 1, polygons 0x0..0x4, surface 0x08000139, type 0x1.

This stride sample is evidence of the defect class, not a substitute for the full installed-DAT post-implementation gate.

7. Required deterministic manifest

OH2 needs a tool-owned evidence manifest that can prove source-to-prepared equivalence without render-thread DAT reads. JSON is appropriate for the evidence artifact; production acdream.pak should carry only the minimum lossless subset metadata required at runtime.

7.1 Header/source identity

Required fields:

  • manifest schema version;
  • bake recipe version;
  • Portal/Cell/HighRes DAT iteration and SHA-256 identity;
  • EnvCell id;
  • Environment DID;
  • CellStruct index;
  • exact cell transform as raw float bits;
  • ordered surface override array, each entry containing:
    • source surface slot/index;
    • raw uint16 override;
    • qualified Surface DID;
    • raw Surface.Type uint32;
    • initial retail mask byte;
    • drawsUnderBuiltEnvCell = ((type & 6) != 0).

7.2 Per-polygon source and candidate trace

For every polygon in exact source ordinal:

  • ordinal and poly_id;
  • num_pts and ordered vertex ids;
  • raw stippling byte and decoded flag names;
  • raw sides_type integer;
  • positive and negative surface index, qualified DID, and raw type;
  • presence/absence of each UV map and ordered raw UV-index bytes;
  • ordered candidate list, with:
    • candidate ordinal;
    • source side (positive/negative);
    • duplicate-copy ordinal;
    • surface index/DID/type;
    • normal sign;
    • reverse-winding boolean;
    • constructedByRetail;
    • drawsUnderBuiltEnvCell;
    • explicit skip/failure reason;
    • exact fan source indices and emitted index order;
    • vertex-dedupe tuples (vertexId, uvIndex, signLane);
    • emitted position/normal/UV float bits or a canonical hash over them.

Allowed reason taxonomy should be finite and tested: degenerate fan, invalid surface index, missing Surface DAT, untextured built-EnvCell draw skip, missing dependency. Retail assumes valid surface indices; corrupt/missing data is an extractor error/quarantine outcome, not license for an alternate rendering algorithm.

7.3 Per-surface subset trace

For every source surface slot:

  • surface index and material owner identity;
  • initial mask and final mask after positive-surface stippling OR;
  • constructed triangle count;
  • drawable triangle count under built-EnvCell admission;
  • first triangle, triangle count, first vertex, last vertex;
  • final nonempty attribute/subset ordinal.

The final manifest hash must exclude pointers, Vulkan handles, upload offsets, timestamps, thread scheduling, and dictionary hash order.

8. Prepared package and migration impact

8.1 Current records

src/AcDream.Content/ObjectMeshData.cs:146-157 currently gives TextureBatchData texture identity/data, indices, a DRW CullMode, derived translucency booleans, and wrapping-UV state. It has no source surface index, raw surface type, retail mask, or immutable subset ordinal.

src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs:211-288 serializes that record inside the common prepared-mesh format. PakFormat currently declares:

CurrentFormatVersion = 2
CurrentBakeToolVersion = 7

Launcher independently pins recipe 7 at LauncherInstallRecordStore.cs:37-41; ContentMigrationCatalog.cs:38-57 contains the recipe-step ledger through 7.

8.2 Minimum production record change

The prepared cell subset needs, directly or through an equally lossless typed record:

  • immutable source surface index / semantic subset ordinal;
  • raw Surface.Type;
  • final retail mask byte;
  • authored raw sides_type only where evidence/validation needs it;
  • a separate fixed cell-shell raster cull state, rather than treating authored sides_type as GPU culling.

Texture payloads may remain deduplicated by texture identity. Indices and material/subset ownership must be recoverable in exact source-surface order; grouping them only by (width,height,format) is insufficient.

8.3 Version decision

Decision: keep package format 2; bump bake recipe 7 → 8.

Rationale:

  • Pak header/directory framing and asset-kind identity do not change.
  • The prepared-mesh payload bytes and extraction recipe do change.
  • Production PakPreparedAssetSource validates the recipe before payload deserialization; bare PakReader is a lower-level framing reader.
  • Existing project precedent uses recipe bumps for prepared-record additions without changing the container format.
  • Every old EnvCell mesh is semantically stale, and the common mesh serializer changes, so an overlay migration cannot prove completeness.

Required migration work:

  1. PakFormat.CurrentBakeToolVersion = 8.
  2. LauncherInstallRecordStore.CurrentBakeToolVersion = 8 in lockstep.
  3. Add ContentMigrationCatalog step 7 → 8, FullRebuild, with a reason naming exact CellStruct surface-index subset construction.
  4. Rebuild all prepared content; do not attempt an EnvCell-only overlay when the shared mesh record schema changed.
  5. Preserve CurrentFormatVersion = 2 and add a test explicitly proving that this is intentional.

9. Smallest exact implementation boundary

The smallest implementation that closes the contract without importing DAT logic onto the render thread is:

  1. Pure Core descriptor. Add one allocation-free/testable function that maps raw sides_type to the ordered candidate descriptors in §3.4. A descriptor carries source side, copy ordinal, normal sign, and reverse- winding. It does not resolve DATs or decide draw admission.
  2. Content surface resolution. In MeshExtractor.PrepareCellStructMeshData, resolve the candidate's source surface slot and raw Surface before deciding anything. NoPos/NoNeg select null/zero UV fallback only.
  3. Surface-index subsets. Aggregate candidates by the source surface index, update the exact retail mask, preserve source polygon order, and emit nonempty subsets in ascending source surface index. Apply the built-EnvCell (type & 6) != 0 admission explicitly; retaining skipped construction in the manifest is sufficient if production chooses not to serialize vertices that can never draw.
  4. Cull separation. Preserve fixed retail cell-shell GPU culling after geometry expansion. Do not use Polygon.SidesType as the rasterizer state.
  5. One interpretation. Route CellMesh.Build through the same descriptor and policy, or remove its rendering production role.
  6. Prepared schema. Add the minimum source-surface/type/mask/order fields described in §8 and round-trip them through ObjectMeshDataSerializer.
  7. Recipe/migration. Apply the recipe-8 full rebuild and retire AP-234 in the same behavioral slice.

This boundary deliberately does not redesign portal views, shell ordering, depth lifecycle, landscape streaming, transparency passes, or camera/view selection. Those are other OVERHAUL slices.

10. Required test matrix

10.1 Pure construction tests

Create synthetic CellStruct fixtures covering the Cartesian product of:

  • sides 0, 1, 2;
  • positive/negative UV present and absent (NoPos, NoNeg);
  • surface types 0x1, 0x2, 0x4, 0x11.

Pin:

  • exact candidate count and order;
  • exact fan index order;
  • ST_DOUBLE second-copy reversal and negative normal;
  • ST_BOTH negative side without reversal;
  • null UV → UV index/coordinates zero, not candidate removal;
  • sign-lane vertex separation and same-lane dedupe;
  • clip-map initial mask 8;
  • alpha/invalpha/additive mask precedence 2;
  • translucent mask 4;
  • signed-byte positive (> 0) positive-surface stippling ORs mask bit 1;
  • negative stippling does not update the negative surface mask;
  • untextured surfaces are constructed but fail built-EnvCell admission;
  • textured NoPos surfaces draw with zero UVs.

10.2 Subset/material tests

  • two distinct surface slots with the same Surface DID remain two subsets;
  • polygons on one slot with different stippling values remain one subset and aggregate the mask;
  • polygons on one slot with different sides values remain one subset;
  • subset order follows ascending source surface index, independent of texture format and dictionary insertion/hash order;
  • source polygon order within a subset is stable;
  • fixed retail CW raster cull remains in force after geometry expansion.

10.3 Canonical evidence tests

  • generate bit-identical manifests twice for all ten requested cells;
  • pin 0xF4180104's eight ST_DOUBLE clip-map polygons and 44 drawable side calls;
  • pin the canonical NoPos surfaces as type 0x11 and verify constructed-then- skipped reasoning;
  • require zero unexplained old/new geometry deltas.

10.4 Installed-DAT scan

Run the complete installed Environment/EnvCell corpus, not the exploratory stride:

  • every old/new admission delta must have the exact Surface.Type & 6 explanation;
  • the known not-NoPos + untextured examples must be corrected;
  • any textured NoPos or ST_BOTH record, if another installed DAT set contains one, must produce the retail candidate/UV behavior;
  • missing/corrupt dependencies must be explicitly counted and identified.

10.5 Package/launcher tests

  • TextureBatchData/new subset record serializer round-trip;
  • truncated/corrupt new fields are rejected deterministically;
  • recipe-7 package is rejected by recipe-8 consumers before payload decode;
  • launcher resolves 7 → 8 to a full rebuild;
  • older-to-8 composed migrations remain full rebuild;
  • package container format remains exactly 2;
  • focused Release suites: Core construction, Content extraction/serializer, Bake package, Launcher.Core migration, App prepared-mesh consumption.

11. Unresolved facts and handoff cautions

  1. The installed Environment CellStruct catalog contains no ST_BOTH; its exact code path is proved by two decompilers but needs synthetic coverage.
  2. The implementation must decide the final production type name and whether all manifest fields stay tool-only. The evidence schema above is binding; the pak should store only fields necessary to preserve semantics.
  3. OH7's eventual immutable ordered draw stream must consume, not erase, the source-surface subset order established here. Atlas grouping is storage, not semantic ownership.
  4. The non-built DrawEnvCell polygon-list fallback is a separate retail path and is not specified here.
  5. The canonical cells produce no visible admission delta under the exact surface rule. Do not claim OH2 alone resolves the cathedral leak/flicker.
  6. Run a complete installed-DAT scan after implementation; the deterministic stride sample is only a before-state signal.

12. Binding implementation checklist

  • side candidates come only from sides_type;
  • NoPos/NoNeg mean UV absence only;
  • ST_DOUBLE second fan reverses; ST_BOTH negative fan does not;
  • normal sign is represented independently of index winding;
  • subset/material owner is source surface-array index;
  • subset order is ascending source surface index;
  • mask initialization and positive stippling OR are exact;
  • built EnvCell draw admission is (Surface.Type & 6) != 0;
  • ordinary GfxObj behavior is unchanged;
  • authored sides type is not used as GPU raster cull;
  • Core and Content do not retain divergent CellStruct interpretations;
  • no render-thread DAT lookup is introduced;
  • acdream.pak recipe becomes 8, format remains 2, migration is full;
  • AP-234 is retired with retail addresses and test evidence;
  • canonical and complete installed-DAT manifests explain every delta.