From 5cd4fd2c716e58647224fd828c3c9f724b6d5c0c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 1 Sep 2026 19:11:32 +0200 Subject: [PATCH] docs(render): define OH2 CellStruct contract --- .../oh2-cellstruct-surface-contract.md | 726 ++++++++++++++++++ 1 file changed, 726 insertions(+) create mode 100644 docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md diff --git a/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md b/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md new file mode 100644 index 00000000..bbf7abb1 --- /dev/null +++ b/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md @@ -0,0 +1,726 @@ +# 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.