# OH2 — retail `CellStruct` surface/subset construction contract **Date:** 2026-09-01 **Campaign:** OVERHAUL / OH2 **Scope:** the built-EnvCell path only: `CEnvCell::UnPack` → `D3DPolyRender::ConstructMesh` → `RenderDeviceD3D::DrawEnvCell` → `D3DPolyRender::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`): ```text 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`: ```text 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: ```text 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: ```text (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; - `copyVert @0x0059C080` writes zero UV coordinates when the UV pointer is absent or the index is out of range (`pseudo-C:424797-424829`); - `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: ```text 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: ```text 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: ```text 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: ```text 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): ```text 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: ```text 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.