feat(core): S1 one CellStruct interpretation, installed-DAT scan, retire AP-234
Campaign OVERHAUL S1 chunk B. - CellMesh.Build's render role is deleted; CellMesh.HasDrawableGeometry is the exact predicate (any sides_type candidate whose resolved surface passes the built-EnvCell (Surface.Type & 6) != 0 test), and the streaming build job consumes it. Core and Content no longer carry two CellStruct interpretations. - Installed-DAT scan over the complete Environment corpus (5,346 landblocks, 729,888 cells, 8,601,560 polygons, 8,608,746 candidates): 3,197 old-rule admissions were untextured surfaces the NoPos proxy let through; zero new-only; zero unexplained; zero unknown sides values; counts pinned as goldens against the contract's DAT hashes. - Canonical pins: 0xF4180104 has eight ST_DOUBLE clip-map polygons and 44 drawable side calls; the canonical NoPos surfaces are type 0x11 and are constructed but skipped; two independent extractions of all ten canonical cells hash identically. - AP-234 retired from the divergence register (161 active rows) with ConstructMesh 0x0059DFA0 / DrawEnvCell 0x0059F170 / DrawMesh 0x0059D4A0. - StipplingSurfaceEquivalenceTests no longer claims a build-time NoPos skip; the cell half is report-only, the building half stays pinned. - Inventory and policy remarks updated; plan ledger records chunk A and the capture kit. Core Meshing+Conformance 95/95, Content 208/208 (Lane!=Timing, Lane!=PreparedPackage), App EnvCell/LandblockBuild/Streaming 432/432, Release solution build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
acf172469e
commit
b681717c30
9 changed files with 631 additions and 154 deletions
File diff suppressed because one or more lines are too long
|
|
@ -416,6 +416,32 @@ heap. Deterministic pressure tests exceed every configured ceiling and prove
|
||||||
zero-charge teardown. Connected evidence:
|
zero-charge teardown. Connected evidence:
|
||||||
`docs/research/2026-07-24-slice-d-unified-residency-report.md`.
|
`docs/research/2026-07-24-slice-d-unified-residency-report.md`.
|
||||||
|
|
||||||
|
**OH2/S1 exact CellStruct surface-index construction (2026-09-02).** Bake
|
||||||
|
recipe 8 replaces `MeshExtractor.PrepareCellStructMeshData`'s NoPos-based
|
||||||
|
approximation with retail's exact `D3DPolyRender::ConstructMesh` (`0x0059DFA0`)
|
||||||
|
side/candidate table: side candidates come only from `CPolygon::sides_type`
|
||||||
|
(`AcDream.Core.Meshing.CellStructSideCandidates`); `NoPos`/`NoNeg` mean "this
|
||||||
|
side's UV-index array is absent" only (`CPolygon::UnPack` `0x00538650`), never
|
||||||
|
face suppression. The subset/material owner is the source surface-array
|
||||||
|
index, not the resolved Surface DID or texture format:
|
||||||
|
`TextureBatchData.SourceSurfaceIndex`/`RetailSurfaceMask`/`RawSurfaceType`/
|
||||||
|
`IsCellShell` carry that fact through the prepared package, and
|
||||||
|
`CellSurfaceSubsets.InAscendingSurfaceOrder` recovers the retail subset walk
|
||||||
|
order independent of the (Width,Height,Format) storage grouping the package
|
||||||
|
still uses for atlas dedup. Built-EnvCell draw admission is
|
||||||
|
`(Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0`
|
||||||
|
(`RenderDeviceD3D::DrawEnvCell` `0x0059F170` →
|
||||||
|
`D3DPolyRender::DrawMesh(..., arg4=1)` `0x0059D4A0`), applied after surface
|
||||||
|
resolution via `RetailUntexturedSurfacePolicy.IsUntextured`, not approximated
|
||||||
|
before it. `CellMesh.Build`'s render-production role is retired; the
|
||||||
|
streaming build job now calls `CellMesh.HasDrawableGeometry` — the identical
|
||||||
|
exact admission rule, evaluated as a predicate only, with no mesh retained —
|
||||||
|
to decide whether a CellStruct contributes drawable geometry. Fixed retail
|
||||||
|
`D3DCULL_CW` (`RenderMeshSubset` `0x0059CA10`) remains the raster cull for
|
||||||
|
every constructed cell-shell subset after fan expansion; the authored
|
||||||
|
`sides_type` is not read as GPU cull state. Retires AP-234. Contract:
|
||||||
|
`docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md`.
|
||||||
|
|
||||||
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
|
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
|
||||||
algorithm, **check this inventory first**. If we already extracted it (🟢
|
algorithm, **check this inventory first**. If we already extracted it (🟢
|
||||||
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
|
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
|
||||||
|
|
|
||||||
|
|
@ -620,8 +620,8 @@ Update immediately when a slice changes state. Chat is not the ledger.
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| v1 OH0 | CLOSED | `oh0-baseline.md` | `b3b7d922` | read-only classification | Release build green; 319 focused / 1 skip | n/a | historical; candidates it committed are S1/S2 seeds |
|
| v1 OH0 | CLOSED | `oh0-baseline.md` | `b3b7d922` | read-only classification | Release build green; 319 focused / 1 skip | n/a | historical; candidates it committed are S1/S2 seeds |
|
||||||
| v1 OH1 | SUPERSEDED | research contracts committed at `5cd4fd2c`/`5d907ae9` (kept, binding) | T0–T3 evidence grammar parked on `quarantine/oh1-evidence-grammar-2026-09-02` | reviews found the grammar a false oracle three times | n/a | n/a | see §15 |
|
| v1 OH1 | SUPERSEDED | research contracts committed at `5cd4fd2c`/`5d907ae9` (kept, binding) | T0–T3 evidence grammar parked on `quarantine/oh1-evidence-grammar-2026-09-02` | reviews found the grammar a false oracle three times | n/a | n/a | see §15 |
|
||||||
| S1 Geometry | IN PROGRESS | `oh2-cellstruct-surface-contract.md` | — | — | — | G1 | recipe 7 → 8 |
|
| S1 Geometry | IN PROGRESS | `oh2-cellstruct-surface-contract.md` (§3.5 arbitrated on the binary 2026-09-02) | chunk A landed `acf17246` (descriptor, extraction, record, serializer, recipe 8); chunk B (CellMesh unification, installed-DAT scan, canonical pins, AP-234, docs) in progress | lead verified chunk A against the named pseudo-C and the paired binary; one inexact absent-UV rule found and corrected before landing; retail review of the whole slice pending | chunk A: Core 32 descriptor tests, Content 170/170, Bake 18/18, Launcher.Core 365/365 (Lane!=Linux), Release solution build 0/0 | G1 pending | recipe 7 → 8; dev pak rebake queued |
|
||||||
| Capture | PENDING | §7 + `tools/walk-oracle/oh/README.md` | scripts drafted; recon owed | — | offsets verified by recon | owner session ~1 h | before S3 |
|
| Capture | READY | §7 + `tools/walk-oracle/oh/README.md` | scripts committed `ebaa41df` + `14d8fe64`; lead resolved DBObj::m_DID=+0x28 and the cdecl alpha-function conventions; recon attach re-confirms | — | recon dt/x/uf dumps at session start | owner session ~1 h | before S3 |
|
||||||
| S2 World graph | PLANNED | `oh1-construction-landscape-contract.md` | — | — | — | G2 | seeds in `b3b7d922` |
|
| S2 World graph | PLANNED | `oh1-construction-landscape-contract.md` | — | — | — | G2 | seeds in `b3b7d922` |
|
||||||
| S3 Walk | PLANNED | built-mesh/world contracts + captures | — | — | — | folded into G3 | |
|
| S3 Walk | PLANNED | built-mesh/world contracts + captures | — | — | — | folded into G3 | |
|
||||||
| S4 Depth + alpha | PLANNED | depth/alpha contracts + captures | — | — | — | G3 | retires AP-34 |
|
| S4 Depth + alpha | PLANNED | depth/alpha contracts + captures | — | — | — | G3 | retires AP-34 |
|
||||||
|
|
|
||||||
|
|
@ -2110,6 +2110,18 @@ namespace AcDream.App.Rendering.Wb
|
||||||
indexSegments);
|
indexSegments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OH2/S1 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
||||||
|
// §3.6 point 5): for a cell-shell mesh (batch.IsCellShell true),
|
||||||
|
// this dictionary walk groups by (Width,Height,Format) for
|
||||||
|
// atlas/storage packing only — it is NOT retail's ascending
|
||||||
|
// source-surface-index subset draw order. The resulting
|
||||||
|
// `renderBatches` list order is what EnvCellRenderer later
|
||||||
|
// replays as draw order. Recovering the exact retail order
|
||||||
|
// requires walking AcDream.Content.CellSurfaceSubsets.
|
||||||
|
// InAscendingSurfaceOrder(meshData) instead; no consumer does
|
||||||
|
// that yet (OH7's ordered draw stream is the intended owner) so
|
||||||
|
// this upload path still does not preserve retail's subset
|
||||||
|
// order for cell shells.
|
||||||
foreach (var (format, batches) in meshData.TextureBatches)
|
foreach (var (format, batches) in meshData.TextureBatches)
|
||||||
{
|
{
|
||||||
foreach (var batch in batches)
|
foreach (var batch in batches)
|
||||||
|
|
|
||||||
|
|
@ -526,9 +526,11 @@ public sealed class LandblockBuildFactory
|
||||||
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
|
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
|
||||||
{
|
{
|
||||||
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
|
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
|
||||||
// WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
|
// WorldEntities with fake MeshRefs. CellMesh.HasDrawableGeometry (OH2/S1
|
||||||
// drawable-geometry predicate; the actual shell placement is now owned
|
// chunk B, retired CellMesh.Build's render-production role and AP-234's
|
||||||
// by this streaming job's EnvCellLandblockBuild transaction.
|
// NoPos approximation with it) remains the drawable-geometry predicate;
|
||||||
|
// the actual shell placement is now owned by this streaming job's
|
||||||
|
// EnvCellLandblockBuild transaction.
|
||||||
// Static objects inside the cell continue to flow through the dispatcher
|
// Static objects inside the cell continue to flow through the dispatcher
|
||||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||||
// fine; EnvCellRenderer receives only the completed shell transaction.
|
// fine; EnvCellRenderer receives only the completed shell transaction.
|
||||||
|
|
@ -548,7 +550,7 @@ public sealed class LandblockBuildFactory
|
||||||
var physicsCellTransform = cellTransform;
|
var physicsCellTransform = cellTransform;
|
||||||
|
|
||||||
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
||||||
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
|
// of whether CellMesh.HasDrawableGeometry reports drawable geometry. A portals-only
|
||||||
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
||||||
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
||||||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||||||
|
|
@ -560,7 +562,8 @@ public sealed class LandblockBuildFactory
|
||||||
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
||||||
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
||||||
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
||||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
bool hasDrawableGeometry =
|
||||||
|
AcDream.Core.Meshing.CellMesh.HasDrawableGeometry(envCell, cellStruct, _dats);
|
||||||
envCellBuild.AddCell(
|
envCellBuild.AddCell(
|
||||||
envCellId,
|
envCellId,
|
||||||
envCell,
|
envCell,
|
||||||
|
|
@ -569,7 +572,7 @@ public sealed class LandblockBuildFactory
|
||||||
physicsCellTransform,
|
physicsCellTransform,
|
||||||
cellOrigin,
|
cellOrigin,
|
||||||
cellTransform,
|
cellTransform,
|
||||||
hasDrawableGeometry: cellSubMeshes.Count > 0);
|
hasDrawableGeometry: hasDrawableGeometry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,141 +1,104 @@
|
||||||
using System.Numerics;
|
using System;
|
||||||
using AcDream.Core.Terrain;
|
using System.Collections.Generic;
|
||||||
using DatReaderWriter;
|
|
||||||
using DatReaderWriter.DBObjs;
|
|
||||||
using AcDream.Core.Content;
|
using AcDream.Core.Content;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
namespace AcDream.Core.Meshing;
|
namespace AcDream.Core.Meshing;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds renderable sub-meshes from an EnvCell's room geometry (walls,
|
/// OH2/S1 chunk B (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
||||||
/// floors, ceilings). The geometry lives in the linked Environment dat:
|
/// §9 item 5): retires this type's former render-production role. The old
|
||||||
/// EnvCell.EnvironmentId → Environment → Cells[CellStructure] → CellStruct.
|
/// <c>Build</c> method carried its own NoPos-based "skip untextured cell
|
||||||
/// This mirrors GfxObjMesh.Build but reads surfaces from EnvCell.Surfaces
|
/// geometry" approximation (AP-234, retired) — a second, divergent
|
||||||
/// (not from the CellStruct itself) and uses the same fan-triangulation
|
/// interpretation of the retail CellStruct surface/subset construction
|
||||||
/// and per-surface deduplication pattern.
|
/// algorithm alongside <c>MeshExtractor.PrepareCellStructMeshData</c>, which
|
||||||
|
/// is now the ONE production interpretation (contract §9 item 5: "Core and
|
||||||
|
/// Content do not retain divergent CellStruct interpretations"). This class
|
||||||
|
/// now exposes only <see cref="HasDrawableGeometry"/>: the exact retail
|
||||||
|
/// predicate for whether a CellStruct contributes at least one drawable
|
||||||
|
/// subset, needed by the streaming build job
|
||||||
|
/// (<c>AcDream.App.Streaming.LandblockBuildFactory</c>) to decide whether a
|
||||||
|
/// cell's shell has drawable geometry before it registers the cell — a
|
||||||
|
/// question answerable without building or retaining any geometry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CellMesh
|
public static class CellMesh
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Walk a CellStruct's polygons and produce one <see cref="GfxObjSubMesh"/>
|
/// True iff at least one of this CellStruct's polygon construction
|
||||||
/// per referenced Surface. Surfaces are resolved from <paramref name="envCell"/>.Surfaces
|
/// candidates (<see cref="CellStructSideCandidates.GetCandidates"/>,
|
||||||
/// (OR'd with 0x08000000 to form the full dat id). Polygons are triangulated as fans.
|
/// ported from <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0, contract
|
||||||
|
/// §3.4) targets a surface slot whose resolved <c>Surface.Type</c> is
|
||||||
|
/// TEXTURED (<see cref="RetailUntexturedSurfacePolicy.IsUntextured"/>
|
||||||
|
/// is false) — exactly the condition under which
|
||||||
|
/// <c>MeshExtractor.PrepareCellStructMeshData</c> would emit at least
|
||||||
|
/// one subset into the prepared package: retail's built-EnvCell draw
|
||||||
|
/// admission is <c>(Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0</c>
|
||||||
|
/// (<c>RenderDeviceD3D::DrawEnvCell</c> @0x0059F170 →
|
||||||
|
/// <c>D3DPolyRender::DrawMesh(..., arg4=1)</c> @0x0059D4A0, contract §4).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="envCell">The EnvCell that owns the surface list.</param>
|
/// <remarks>
|
||||||
/// <param name="cellStruct">The CellStruct containing the polygon + vertex geometry.</param>
|
/// Side candidates come only from <c>CPolygon::sides_type</c>
|
||||||
/// <param name="dats">
|
/// (contract §3.4); <c>NoPos</c>/<c>NoNeg</c> play no role in this
|
||||||
/// Optional dat collection used to read Surface.Type flags and set
|
/// predicate — they mean "this side's UV-index array is absent"
|
||||||
/// <see cref="GfxObjSubMesh.Translucency"/>. When null (e.g. offline tests)
|
/// (contract §2.3/§3.5), which cannot change whether a subset draws,
|
||||||
/// all sub-meshes default to <see cref="TranslucencyKind.Opaque"/>.
|
/// only what its texture coordinates are. This predicate therefore
|
||||||
|
/// never reads a polygon's UV-index arrays.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="envCell">
|
||||||
|
/// The EnvCell whose ordered surface-override array
|
||||||
|
/// (<see cref="EnvCell.Surfaces"/>) resolves each candidate's source
|
||||||
|
/// surface-array slot to a qualified Surface DAT id, exactly like
|
||||||
|
/// <c>MeshExtractor.PrepareCellStructMeshData</c>'s
|
||||||
|
/// <c>surfaceOverrides</c> parameter.
|
||||||
/// </param>
|
/// </param>
|
||||||
public static IReadOnlyList<GfxObjSubMesh> Build(EnvCell envCell, CellStruct cellStruct, IDatObjectSource? dats = null)
|
/// <param name="cellStruct">The CellStruct containing the polygon geometry.</param>
|
||||||
|
/// <param name="dats">DAT object source used to resolve each candidate's surface <c>Surface.Type</c>.</param>
|
||||||
|
public static bool HasDrawableGeometry(EnvCell envCell, CellStruct cellStruct, IDatObjectSource dats)
|
||||||
{
|
{
|
||||||
// Group output vertices and indices per surface dat id.
|
// One resolve per distinct surface slot, not per polygon: a slot's
|
||||||
var perSurface = new Dictionary<uint, (List<Vertex> Vertices, List<uint> Indices, Dictionary<(int pos, int uv), uint> Dedupe)>();
|
// textured-ness is a per-slot fact (contract §3.2's per-surface
|
||||||
|
// mask/type), and re-resolving the same Surface DAT record for
|
||||||
|
// every polygon that references it would be wasted DAT I/O on the
|
||||||
|
// streaming worker thread that calls this predicate.
|
||||||
|
var slotIsTextured = new Dictionary<int, bool>();
|
||||||
|
|
||||||
foreach (var kvp in cellStruct.Polygons)
|
bool SlotIsTextured(int slot)
|
||||||
{
|
{
|
||||||
var poly = kvp.Value;
|
if (slotIsTextured.TryGetValue(slot, out bool cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
if (poly.VertexIds.Count < 3)
|
bool textured = false;
|
||||||
continue; // degenerate polygon
|
if (slot >= 0 && slot < envCell.Surfaces.Count)
|
||||||
|
|
||||||
// Retail's RenderDeviceD3D::DrawEnvCell (@0x0059f170) calls
|
|
||||||
// D3DPolyRender::DrawMesh with arg4=1, which skips every
|
|
||||||
// UNTEXTURED subset inside an EnvCell interior — unlike ordinary
|
|
||||||
// objects, which draw them (see RetailUntexturedSurfacePolicy /
|
|
||||||
// RetailUntexturedSubsetPolicy, #426). We approximate
|
|
||||||
// "untextured" here with the polygon's own NoPos stippling flag
|
|
||||||
// rather than resolving the Surface's own Type
|
|
||||||
// (Base1Image/Base1ClipMap) before this per-polygon decision —
|
|
||||||
// see docs/architecture/retail-divergence-register.md AP-234. Do
|
|
||||||
// NOT remove this gate the way #426 removed the matching gate in
|
|
||||||
// GfxObjMesh.Build/MeshExtractor.PrepareGfxObjMeshData — retail
|
|
||||||
// genuinely skips untextured cell geometry, unlike ordinary
|
|
||||||
// objects.
|
|
||||||
if (poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
int surfaceIdx = poly.PosSurface;
|
|
||||||
if (surfaceIdx < 0 || surfaceIdx >= envCell.Surfaces.Count)
|
|
||||||
continue; // out-of-range surface index
|
|
||||||
|
|
||||||
// Surfaces on EnvCell are unqualified ids; OR with 0x08000000 for the full dat id.
|
|
||||||
uint surfaceId = (uint)envCell.Surfaces[surfaceIdx] | 0x08000000u;
|
|
||||||
|
|
||||||
if (!perSurface.TryGetValue(surfaceId, out var bucket))
|
|
||||||
{
|
{
|
||||||
bucket = (new List<Vertex>(), new List<uint>(), new Dictionary<(int, int), uint>());
|
uint surfaceId = 0x08000000u | envCell.Surfaces[slot];
|
||||||
perSurface[surfaceId] = bucket;
|
if (dats.Get<Surface>(surfaceId) is { } surface)
|
||||||
|
textured = !RetailUntexturedSurfacePolicy.IsUntextured(surface.Type);
|
||||||
}
|
}
|
||||||
|
slotIsTextured[slot] = textured;
|
||||||
|
return textured;
|
||||||
|
}
|
||||||
|
|
||||||
// Collect output vertex indices for this polygon.
|
foreach (var poly in cellStruct.Polygons.Values)
|
||||||
var polyOut = new List<uint>(poly.VertexIds.Count);
|
{
|
||||||
bool skipPoly = false;
|
// Same degenerate-fan gate as MeshExtractor.PrepareCellStructMeshData.
|
||||||
|
if (poly.VertexIds.Count < 3) continue;
|
||||||
|
|
||||||
for (int i = 0; i < poly.VertexIds.Count; i++)
|
ReadOnlySpan<CellStructSideCandidate> candidates =
|
||||||
|
CellStructSideCandidates.GetCandidates((int)poly.SidesType);
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
int posIdx = poly.VertexIds[i];
|
short surfaceIdxRaw = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive
|
||||||
int uvIdx = i < poly.PosUVIndices.Count ? poly.PosUVIndices[i] : 0;
|
? poly.PosSurface
|
||||||
|
: poly.NegSurface;
|
||||||
|
if (surfaceIdxRaw < 0) continue;
|
||||||
|
|
||||||
if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)posIdx, out var sw))
|
if (SlotIsTextured(surfaceIdxRaw))
|
||||||
{
|
return true;
|
||||||
skipPoly = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var texcoord = uvIdx >= 0 && uvIdx < sw.UVs.Count
|
|
||||||
? new Vector2(sw.UVs[uvIdx].U, sw.UVs[uvIdx].V)
|
|
||||||
: Vector2.Zero;
|
|
||||||
|
|
||||||
// Use normal from vertex data; fall back to up-vector if missing.
|
|
||||||
var normal = sw.Normal != Vector3.Zero ? sw.Normal : Vector3.UnitZ;
|
|
||||||
|
|
||||||
var key = (posIdx, uvIdx);
|
|
||||||
if (!bucket.Dedupe.TryGetValue(key, out var outIdx))
|
|
||||||
{
|
|
||||||
outIdx = (uint)bucket.Vertices.Count;
|
|
||||||
bucket.Vertices.Add(new Vertex(sw.Origin, normal, texcoord, TerrainLayer: 0));
|
|
||||||
bucket.Dedupe[key] = outIdx;
|
|
||||||
}
|
|
||||||
polyOut.Add(outIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipPoly || polyOut.Count < 3)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Fan triangulation: (v0, v1, v2), (v0, v2, v3), ...
|
|
||||||
for (int i = 1; i < polyOut.Count - 1; i++)
|
|
||||||
{
|
|
||||||
bucket.Indices.Add(polyOut[0]);
|
|
||||||
bucket.Indices.Add(polyOut[i]);
|
|
||||||
bucket.Indices.Add(polyOut[i + 1]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit one sub-mesh per surface.
|
return false;
|
||||||
var result = new List<GfxObjSubMesh>(perSurface.Count);
|
|
||||||
foreach (var kvp in perSurface)
|
|
||||||
{
|
|
||||||
// Resolve Surface.Type flags when a DatCollection is available so the
|
|
||||||
// renderer can split the draw into opaque and translucent passes.
|
|
||||||
var translucency = TranslucencyKind.Opaque;
|
|
||||||
if (dats is not null)
|
|
||||||
{
|
|
||||||
var surface = dats.Get<Surface>(kvp.Key);
|
|
||||||
if (surface is not null)
|
|
||||||
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Add(new GfxObjSubMesh(
|
|
||||||
SurfaceId: kvp.Key,
|
|
||||||
Vertices: kvp.Value.Vertices.ToArray(),
|
|
||||||
Indices: kvp.Value.Indices.ToArray())
|
|
||||||
{
|
|
||||||
Translucency = translucency,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,19 @@ public static class RetailUntexturedSurfacePolicy
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// EnvCell interiors are retail's OTHER "skip untextured" case
|
/// EnvCell interiors are retail's OTHER "skip untextured" case
|
||||||
/// (<c>RenderDeviceD3D::DrawEnvCell</c> @0x0059f170 calls
|
/// (<c>RenderDeviceD3D::DrawEnvCell</c> @0x0059f170 calls
|
||||||
/// <c>DrawMesh(..., arg4: 1)</c>), but EnvCell/CellStruct geometry never
|
/// <c>DrawMesh(..., arg4: 1)</c>). As of OH2/S1 (2026-09-02,
|
||||||
/// reaches this predicate — it draws through <c>EnvCellRenderer</c> /
|
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md),
|
||||||
/// <c>MeshExtractor.PrepareCellStructMeshData</c>, which keeps its own
|
/// EnvCell/CellStruct geometry reaches this SAME predicate directly, with no
|
||||||
/// NoPos-based approximation of the same rule (see
|
/// proxy: <c>MeshExtractor.PrepareCellStructMeshData</c> resolves each
|
||||||
/// <c>docs/architecture/retail-divergence-register.md</c> AP-234 and
|
/// candidate's source surface slot FIRST, then calls
|
||||||
/// <c>CellMesh.cs</c>'s matching gate).
|
/// <see cref="IsUntextured"/> on the resolved <c>Surface.Type</c> before
|
||||||
|
/// deciding whether to emit that slot's subset, and
|
||||||
|
/// <c>CellMesh.HasDrawableGeometry</c> (the streaming build job's
|
||||||
|
/// drawable-geometry predicate, formerly <c>CellMesh.Build</c>) does the
|
||||||
|
/// same. The old build-time <c>Stippling.NoPos</c> approximation
|
||||||
|
/// (<c>docs/architecture/retail-divergence-register.md</c> AP-234) is
|
||||||
|
/// retired — NoPos/NoNeg now mean only "this side's UV-index array is
|
||||||
|
/// absent" (contract §2.3/§3.5), never face or subset suppression.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ONE shared predicate for <c>WbDrawDispatcher</c>'s classic classifier
|
/// ONE shared predicate for <c>WbDrawDispatcher</c>'s classic classifier
|
||||||
/// (<c>ClassifyBatches</c>), packed classifier (<c>ClassifyPackedBatches</c>),
|
/// (<c>ClassifyBatches</c>), packed classifier (<c>ClassifyPackedBatches</c>),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using RetailCullMode = DatReaderWriter.Enums.CullMode;
|
||||||
|
|
||||||
|
namespace AcDream.Content.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OH2/S1 chunk B (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
||||||
|
/// §10.3/§10.4): the installed-DAT proof that retail's exact CellStruct
|
||||||
|
/// surface/subset construction (<see cref="CellStructSideCandidates"/>,
|
||||||
|
/// <c>MeshExtractor.PrepareCellStructMeshData</c>,
|
||||||
|
/// <see cref="RetailUntexturedSurfacePolicy"/>) replaces the retired
|
||||||
|
/// build-time <c>Stippling.NoPos</c> approximation (AP-234) without
|
||||||
|
/// changing what the COMPLETE installed corpus admits.
|
||||||
|
///
|
||||||
|
/// DAT identity this evidence was captured against (contract §1):
|
||||||
|
/// client_portal.dat DC6E500BA22E6B186DB7171E3F3345238B6444C85D798ADC85E550973B8D12E4
|
||||||
|
/// client_cell_1.dat 6DB0ABF00FBCEED62C3F1EE842EE7C1F423D732BED77A5B7C102EE89A52AB99E
|
||||||
|
/// client_highres.dat 503E0828D14F2F9CCBC31431E1055AC188464BF4B499DE37F4C3D5B2D9F3E727
|
||||||
|
/// client_local_English.dat E85C820280C88FAC7DF6C8043F5E24596E9C8774193AF4123D756546F78FB2BB
|
||||||
|
/// A hash change on any of these DATs is expected to move the pinned counts
|
||||||
|
/// below; that is the point of pinning them (a silent regression in the
|
||||||
|
/// extraction algorithm itself would move them too, on the SAME dat set).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Lane", "InstalledDat")]
|
||||||
|
public sealed class CellStructSurfaceConstructionInstalledDatTests {
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public CellStructSurfaceConstructionInstalledDatTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
// Contract §10.3's ten canonical evidence cells: eight cathedral-area
|
||||||
|
// cells sharing three Environments, plus the Facility Hub's two-cell
|
||||||
|
// vertical stair pair.
|
||||||
|
private static readonly uint[] CanonicalCellIds = {
|
||||||
|
0xF4180100u, 0xF4180101u, 0xF4180104u, 0xF4180106u, 0xF4180107u,
|
||||||
|
0xF4180112u, 0xF4180113u, 0xF4180114u,
|
||||||
|
0x8A02015Eu, 0x8A02015Fu,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static MeshExtractor NewExtractor(DatCollection dats) {
|
||||||
|
var reader = new DatCollectionAdapter(dats);
|
||||||
|
return new MeshExtractor(reader, new TestConsoleLogger(), sideStagedSink: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// §10.4 — complete installed-DAT admission-delta scan.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks every EnvCell actually present in client_cell_1.dat (not the
|
||||||
|
/// contract's exploratory 89th-cell stride sample) and, for every
|
||||||
|
/// polygon construction candidate
|
||||||
|
/// (<see cref="CellStructSideCandidates.GetCandidates"/>), classifies
|
||||||
|
/// admission under the OLD rule (<c>!NoPos</c> on the positive side,
|
||||||
|
/// <c>!NoNeg</c> on the ST_BOTH negative side — MeshExtractor's
|
||||||
|
/// pre-OH2 <c>hasPos</c>/<c>hasNeg</c> gate, contract §5.1) against the
|
||||||
|
/// exact NEW rule (resolved <c>Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)
|
||||||
|
/// ) != 0</c>). Every delta must be explained by the surface-type fact
|
||||||
|
/// that produced it: this is the complete-corpus successor to the
|
||||||
|
/// contract §6 exploratory stride sample (which found 55 concrete
|
||||||
|
/// "not-NoPos + untextured" false admissions under the OLD rule).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstalledDatScan_EveryOldNewAdmissionDeltaIsExplainedBySurfaceType() {
|
||||||
|
var datDir = ContentConformanceDats.ResolveDatDir();
|
||||||
|
if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; }
|
||||||
|
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
long landblocksVisited = 0;
|
||||||
|
long landblocksWithCells = 0;
|
||||||
|
long cellsVisited = 0;
|
||||||
|
long cellsWithGeometry = 0;
|
||||||
|
long missingEnvironmentOrStruct = 0;
|
||||||
|
long polygons = 0;
|
||||||
|
long candidates = 0;
|
||||||
|
long unknownSidesType = 0;
|
||||||
|
long unresolvedSurfaceSlots = 0;
|
||||||
|
long oldOnly = 0; // old=emit, new=skip
|
||||||
|
long newOnly = 0; // old=skip, new=emit
|
||||||
|
var unexplained = new List<string>();
|
||||||
|
|
||||||
|
// Per-EnvCell surface cache: one wall/floor texture is referenced by
|
||||||
|
// many polygons within a single cell; re-resolving the same Surface
|
||||||
|
// dat record per polygon would be wasted I/O across ~10^5 EnvCells.
|
||||||
|
var slotCache = new Dictionary<int, Surface?>();
|
||||||
|
|
||||||
|
// Neither EnvCell nor LandBlockInfo carries a usable
|
||||||
|
// Chorizite.DatReaderWriter DBObjType id-range/mask (both are
|
||||||
|
// landblock-RELATIVE types — every landblock reuses the same low
|
||||||
|
// 16-bit id space 0x0100..0xFFFD for its own cells — so
|
||||||
|
// DatCollection.GetAllIdsOfType<EnvCell>()/<LandBlockInfo>() return
|
||||||
|
// EMPTY: verified empirically 2026-09-02, then confirmed by
|
||||||
|
// decompiling DatReaderWriter.Lib.DBObjAttributeCache via ilspycmd
|
||||||
|
// (EnvCell's own [DBObjType] attribute carries FirstId=LastId=
|
||||||
|
// MaskId=0; DatDatabase.GetAllIdsOfType only consults
|
||||||
|
// Tree.GetFilesInRange(FirstId,LastId), never the per-landblock
|
||||||
|
// catch-all TypeFromId path DBObjTypeFromId falls back to for the
|
||||||
|
// Cell dat). The complete corpus therefore requires the same
|
||||||
|
// landblock-prefix walk LandblockBuildFactory/StipplingSurface
|
||||||
|
// EquivalenceTests already use per-fixture, run over the WHOLE
|
||||||
|
// 0x0000-0xFFFF landblock-prefix space instead of a hardcoded
|
||||||
|
// fixture list — exactly the shape of the contract §6 exploratory
|
||||||
|
// sample's own "734,976 candidate EnvCell ids", but complete
|
||||||
|
// (every candidate, not every 89th).
|
||||||
|
for (uint landblockPrefix = 0; landblockPrefix <= 0xFFFFu; landblockPrefix++) {
|
||||||
|
uint lbId = landblockPrefix << 16;
|
||||||
|
var lbInfo = dats.Get<LandBlockInfo>(lbId | 0xFFFEu);
|
||||||
|
if (lbInfo is null) continue;
|
||||||
|
landblocksVisited++;
|
||||||
|
if (lbInfo.NumCells == 0) continue;
|
||||||
|
landblocksWithCells++;
|
||||||
|
|
||||||
|
for (uint low = 0x0100u; low < 0x0100u + lbInfo.NumCells; low++) {
|
||||||
|
uint envCellId = lbId | low;
|
||||||
|
cellsVisited++;
|
||||||
|
var envCell = dats.Get<EnvCell>(envCellId);
|
||||||
|
if (envCell is null || envCell.EnvironmentId == 0) continue;
|
||||||
|
|
||||||
|
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||||
|
if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) {
|
||||||
|
missingEnvironmentOrStruct++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cellsWithGeometry++;
|
||||||
|
slotCache.Clear();
|
||||||
|
|
||||||
|
Surface? ResolveSlot(int slot) {
|
||||||
|
if (slotCache.TryGetValue(slot, out var cached)) return cached;
|
||||||
|
Surface? resolved = null;
|
||||||
|
if (slot >= 0 && slot < envCell.Surfaces.Count) {
|
||||||
|
uint surfaceId = 0x08000000u | envCell.Surfaces[slot];
|
||||||
|
resolved = dats.Get<Surface>(surfaceId);
|
||||||
|
}
|
||||||
|
slotCache[slot] = resolved;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (polyId, poly) in cellStruct.Polygons) {
|
||||||
|
if (poly.VertexIds.Count < 3) continue;
|
||||||
|
polygons++;
|
||||||
|
|
||||||
|
ReadOnlySpan<CellStructSideCandidate> polyCandidates =
|
||||||
|
CellStructSideCandidates.GetCandidates((int)poly.SidesType);
|
||||||
|
if (polyCandidates.Length == 0) {
|
||||||
|
unknownSidesType++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var candidate in polyCandidates) {
|
||||||
|
candidates++;
|
||||||
|
bool isPositive = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive;
|
||||||
|
short slotRaw = isPositive ? poly.PosSurface : poly.NegSurface;
|
||||||
|
|
||||||
|
bool oldAdmitted = isPositive
|
||||||
|
? !poly.Stippling.HasFlag(StipplingType.NoPos)
|
||||||
|
: !poly.Stippling.HasFlag(StipplingType.NoNeg);
|
||||||
|
|
||||||
|
Surface? surface = ResolveSlot(slotRaw);
|
||||||
|
if (surface is null) {
|
||||||
|
unresolvedSurfaceSlots++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool newAdmitted = (surface.Type & (SurfaceType.Base1Image | SurfaceType.Base1ClipMap)) != 0;
|
||||||
|
|
||||||
|
if (oldAdmitted && !newAdmitted) {
|
||||||
|
oldOnly++;
|
||||||
|
bool untextured = (surface.Type & (SurfaceType.Base1Image | SurfaceType.Base1ClipMap)) == 0;
|
||||||
|
if (!untextured)
|
||||||
|
unexplained.Add($"OLD-ONLY unexplained: envCell=0x{envCellId:X8} env=0x{envId:X8} struct={envCell.CellStructure} poly={polyId} side={candidate.SurfaceSlot} slot={slotRaw} type=0x{(uint)surface.Type:X8}");
|
||||||
|
}
|
||||||
|
else if (!oldAdmitted && newAdmitted) {
|
||||||
|
newOnly++;
|
||||||
|
bool uvAbsenceBitSet = isPositive
|
||||||
|
? poly.Stippling.HasFlag(StipplingType.NoPos)
|
||||||
|
: poly.Stippling.HasFlag(StipplingType.NoNeg);
|
||||||
|
if (!uvAbsenceBitSet)
|
||||||
|
unexplained.Add($"NEW-ONLY unexplained: envCell=0x{envCellId:X8} env=0x{envId:X8} struct={envCell.CellStructure} poly={polyId} side={candidate.SurfaceSlot} slot={slotRaw} type=0x{(uint)surface.Type:X8} stippling={poly.Stippling}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.Stop();
|
||||||
|
_out.WriteLine($"duration: {stopwatch.Elapsed}");
|
||||||
|
_out.WriteLine($"landblocksVisited={landblocksVisited} landblocksWithCells={landblocksWithCells}");
|
||||||
|
_out.WriteLine($"cellsVisited={cellsVisited} cellsWithGeometry={cellsWithGeometry} missingEnvironmentOrStruct={missingEnvironmentOrStruct}");
|
||||||
|
_out.WriteLine($"polygons={polygons} candidates={candidates} unknownSidesType={unknownSidesType} unresolvedSurfaceSlots={unresolvedSurfaceSlots}");
|
||||||
|
_out.WriteLine($"oldOnly={oldOnly} newOnly={newOnly} unexplained={unexplained.Count}");
|
||||||
|
foreach (var line in unexplained.Take(50)) _out.WriteLine(" " + line);
|
||||||
|
|
||||||
|
Assert.Empty(unexplained);
|
||||||
|
|
||||||
|
// Golden counts pinned against the DAT identity in the class
|
||||||
|
// docstring (contract §1). A DAT change or an extraction-algorithm
|
||||||
|
// regression moves these; that visibility is the point of pinning
|
||||||
|
// them (contract §10.4's "run the complete installed Environment/
|
||||||
|
// EnvCell corpus... every old/new admission delta must have the
|
||||||
|
// exact Surface.Type & 6 explanation").
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.CellsVisited, cellsVisited);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.CellsWithGeometry, cellsWithGeometry);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.MissingEnvironmentOrStruct, missingEnvironmentOrStruct);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.Polygons, polygons);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.Candidates, candidates);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.UnknownSidesType, unknownSidesType);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.UnresolvedSurfaceSlots, unresolvedSurfaceSlots);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.OldOnly, oldOnly);
|
||||||
|
Assert.Equal(CellStructInstalledDatGolden.NewOnly, newOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// §10.3 — canonical evidence pins.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pin 1: 0xF4180104's eight ST_DOUBLE clip-map polygons and 44 total
|
||||||
|
/// drawable side calls across the WHOLE cell — retail's 36 ordinary
|
||||||
|
/// (forward, positive-normal) drawable candidates plus the 8 additional
|
||||||
|
/// reversed negative-normal copies the ST_DOUBLE polygons contribute
|
||||||
|
/// (contract §6): the old NoPos-proxy audit's "36-positive-only"
|
||||||
|
/// headline never counted the second copy at all.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Cell0xF4180104_HasEightStDoubleClipMapPolygonsAndFortyFourDrawableSideCalls() {
|
||||||
|
var datDir = ContentConformanceDats.ResolveDatDir();
|
||||||
|
if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
const uint envCellId = 0xF4180104u;
|
||||||
|
var (envCell, _, cellStruct) = ResolveCanonicalCell(dats, envCellId);
|
||||||
|
|
||||||
|
int stDoubleCount = 0;
|
||||||
|
int drawableSideCalls = 0;
|
||||||
|
var slotCache = new Dictionary<int, Surface?>();
|
||||||
|
|
||||||
|
Surface? ResolveSlot(int slot) {
|
||||||
|
if (slotCache.TryGetValue(slot, out var cached)) return cached;
|
||||||
|
Surface? resolved = null;
|
||||||
|
if (slot >= 0 && slot < envCell.Surfaces.Count) {
|
||||||
|
uint surfaceId = 0x08000000u | envCell.Surfaces[slot];
|
||||||
|
resolved = dats.Get<Surface>(surfaceId);
|
||||||
|
}
|
||||||
|
slotCache[slot] = resolved;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var poly in cellStruct.Polygons.Values) {
|
||||||
|
if (poly.VertexIds.Count < 3) continue;
|
||||||
|
if (poly.SidesType == RetailCullMode.None) stDoubleCount++; // raw sides_type 1 = ST_DOUBLE
|
||||||
|
|
||||||
|
foreach (var candidate in CellStructSideCandidates.GetCandidates((int)poly.SidesType)) {
|
||||||
|
bool isPositive = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive;
|
||||||
|
short slotRaw = isPositive ? poly.PosSurface : poly.NegSurface;
|
||||||
|
var surface = ResolveSlot(slotRaw);
|
||||||
|
if (surface is null) continue;
|
||||||
|
if (!RetailUntexturedSurfacePolicy.IsUntextured(surface.Type))
|
||||||
|
drawableSideCalls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"0xF4180104: stDoubleCount={stDoubleCount} drawableSideCalls={drawableSideCalls}");
|
||||||
|
Assert.Equal(8, stDoubleCount);
|
||||||
|
Assert.Equal(44, drawableSideCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pin 2: across all ten canonical cells, every polygon whose positive
|
||||||
|
/// side is NoPos-stippled resolves to a Surface whose raw Type is
|
||||||
|
/// exactly 0x11 (BASE1_SOLID | TRANSLUCENT — untextured), AND that
|
||||||
|
/// surface slot is absent from the emitted prepared mesh (constructed —
|
||||||
|
/// the slot has at least one admission candidate by
|
||||||
|
/// <see cref="CellStructSideCandidates.GetCandidates"/> construction —
|
||||||
|
/// but skipped at built-EnvCell admission, contract §4/§6).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CanonicalCells_NoPosSurfacesAreType0x11AndConstructedButSkipped() {
|
||||||
|
var datDir = ContentConformanceDats.ResolveDatDir();
|
||||||
|
if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var extractor = NewExtractor(dats);
|
||||||
|
|
||||||
|
int noPosSurfacesChecked = 0;
|
||||||
|
foreach (uint envCellId in CanonicalCellIds) {
|
||||||
|
var (envCell, _, cellStruct) = ResolveCanonicalCell(dats, envCellId);
|
||||||
|
|
||||||
|
var noPosSlots = new HashSet<int>();
|
||||||
|
foreach (var poly in cellStruct.Polygons.Values) {
|
||||||
|
if (poly.VertexIds.Count < 3) continue;
|
||||||
|
if (!poly.Stippling.HasFlag(StipplingType.NoPos)) continue;
|
||||||
|
if (poly.PosSurface < 0 || poly.PosSurface >= envCell.Surfaces.Count) continue;
|
||||||
|
|
||||||
|
uint surfaceId = 0x08000000u | envCell.Surfaces[poly.PosSurface];
|
||||||
|
var surface = dats.Get<Surface>(surfaceId);
|
||||||
|
Assert.NotNull(surface);
|
||||||
|
Assert.Equal(0x11u, (uint)surface!.Type);
|
||||||
|
noPosSlots.Add(poly.PosSurface);
|
||||||
|
noPosSurfacesChecked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noPosSlots.Count == 0) continue;
|
||||||
|
|
||||||
|
ObjectMeshData? mesh = extractor.PrepareMeshData(envCellId | 0x1_0000_0000UL, isSetup: false);
|
||||||
|
Assert.NotNull(mesh);
|
||||||
|
var emittedSlots = CellSurfaceSubsets.InAscendingSurfaceOrder(mesh!)
|
||||||
|
.Select(b => b.SourceSurfaceIndex)
|
||||||
|
.ToHashSet();
|
||||||
|
foreach (int slot in noPosSlots)
|
||||||
|
Assert.DoesNotContain(slot, emittedSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"noPosSurfacesChecked={noPosSurfacesChecked} across {CanonicalCellIds.Length} canonical cells");
|
||||||
|
Assert.True(noPosSurfacesChecked > 0, "expected at least one NoPos polygon across the canonical cells (contract §6)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pin 3: two independent extraction runs of every canonical cell
|
||||||
|
/// produce a bit-identical SHA-256 over the emitted subset records
|
||||||
|
/// (SourceSurfaceIndex, RawSurfaceType, RetailSurfaceMask, index count,
|
||||||
|
/// total mesh vertex count), walked in
|
||||||
|
/// <see cref="CellSurfaceSubsets.InAscendingSurfaceOrder"/> — proving
|
||||||
|
/// the ascending-source-surface-index order is independent of
|
||||||
|
/// dictionary/hash iteration order (contract §3.6, §10.3).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CanonicalCells_EmittedSubsetHash_IsDeterministicAcrossTwoRuns() {
|
||||||
|
var datDir = ContentConformanceDats.ResolveDatDir();
|
||||||
|
if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
foreach (uint envCellId in CanonicalCellIds) {
|
||||||
|
// Fresh MeshExtractor per run: proves determinism is a property
|
||||||
|
// of the extraction algorithm itself, not of any state an
|
||||||
|
// extractor instance happens to retain (decoded-texture cache,
|
||||||
|
// solid-color cache) across calls.
|
||||||
|
var extractor1 = NewExtractor(dats);
|
||||||
|
var extractor2 = NewExtractor(dats);
|
||||||
|
|
||||||
|
ObjectMeshData? mesh1 = extractor1.PrepareMeshData(envCellId | 0x1_0000_0000UL, isSetup: false);
|
||||||
|
ObjectMeshData? mesh2 = extractor2.PrepareMeshData(envCellId | 0x1_0000_0000UL, isSetup: false);
|
||||||
|
Assert.NotNull(mesh1);
|
||||||
|
Assert.NotNull(mesh2);
|
||||||
|
|
||||||
|
string hash1 = HashCellSubsets(mesh1!);
|
||||||
|
string hash2 = HashCellSubsets(mesh2!);
|
||||||
|
_out.WriteLine($"0x{envCellId:X8} hash={hash1}");
|
||||||
|
Assert.Equal(hash1, hash2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashCellSubsets(ObjectMeshData mesh) {
|
||||||
|
using var stream = new System.IO.MemoryStream();
|
||||||
|
using (var writer = new System.IO.BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true)) {
|
||||||
|
writer.Write(mesh.Vertices.Length);
|
||||||
|
foreach (var batch in CellSurfaceSubsets.InAscendingSurfaceOrder(mesh)) {
|
||||||
|
writer.Write(batch.SourceSurfaceIndex);
|
||||||
|
writer.Write(batch.RawSurfaceType);
|
||||||
|
writer.Write(batch.RetailSurfaceMask);
|
||||||
|
writer.Write(batch.Indices.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Convert.ToHexString(SHA256.HashData(stream.ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (EnvCell envCell, uint envId, DatReaderWriter.Types.CellStruct cellStruct) ResolveCanonicalCell(
|
||||||
|
DatCollection dats, uint envCellId) {
|
||||||
|
var envCell = dats.Get<EnvCell>(envCellId);
|
||||||
|
Assert.NotNull(envCell);
|
||||||
|
uint envId = 0x0D000000u | envCell!.EnvironmentId;
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||||
|
Assert.NotNull(environment);
|
||||||
|
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cellStruct),
|
||||||
|
$"EnvCell 0x{envCellId:X8}: Environment 0x{envId:X8} has no CellStruct {envCell.CellStructure}");
|
||||||
|
return (envCell, envId, cellStruct!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Golden counts for
|
||||||
|
/// <see cref="CellStructSurfaceConstructionInstalledDatTests.InstalledDatScan_EveryOldNewAdmissionDeltaIsExplainedBySurfaceType"/>,
|
||||||
|
/// pinned against the DAT identity in that class's docstring. Regenerate by
|
||||||
|
/// reading the test's own diagnostic output after a deliberate DAT/
|
||||||
|
/// extraction-algorithm change and updating every field together — a
|
||||||
|
/// partial update would silently weaken the pin.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CellStructInstalledDatGolden {
|
||||||
|
// Captured 2026-09-02 against the DAT identity in the class docstring
|
||||||
|
// (contract §1); landblocksVisited=5346, landblocksWithCells=3405 are
|
||||||
|
// printed diagnostics only (not pinned — they describe the world's
|
||||||
|
// overall building/dungeon density, orthogonal to this contract).
|
||||||
|
public const long CellsVisited = 729888;
|
||||||
|
public const long CellsWithGeometry = 729888;
|
||||||
|
public const long MissingEnvironmentOrStruct = 0;
|
||||||
|
public const long Polygons = 8601560;
|
||||||
|
public const long Candidates = 8608746;
|
||||||
|
public const long UnknownSidesType = 0;
|
||||||
|
public const long UnresolvedSurfaceSlots = 0;
|
||||||
|
|
||||||
|
// oldOnly (old=emit/new=skip, i.e. "not-NoPos + untextured" — the exact
|
||||||
|
// false-admission class the contract's own exploratory 1-in-89 stride
|
||||||
|
// sample estimated at 55 hits in 97,020 polygons): the complete corpus
|
||||||
|
// finds 3,197 in 8,601,560 polygons, same order of magnitude and same
|
||||||
|
// direction. newOnly (old=skip/new=emit, i.e. "NoPos + textured") is
|
||||||
|
// exactly 0 across the WHOLE installed corpus — the complete-corpus
|
||||||
|
// confirmation of the stride sample's own "NoPos + textured surface = 0"
|
||||||
|
// finding (contract §6).
|
||||||
|
public const long OldOnly = 3197;
|
||||||
|
public const long NewOnly = 0;
|
||||||
|
}
|
||||||
|
|
@ -14,19 +14,53 @@ namespace AcDream.Core.Tests.Conformance;
|
||||||
/// Retail suppresses portal-fill drawing at DRAW time via the skipNoTexture
|
/// Retail suppresses portal-fill drawing at DRAW time via the skipNoTexture
|
||||||
/// rule: building/cell surface batches whose CSurface.type lacks BASE1_IMAGE
|
/// rule: building/cell surface batches whose CSurface.type lacks BASE1_IMAGE
|
||||||
/// (0x2) and BASE1_CLIPMAP (0x4) are skipped (D3DPolyRender inner draw,
|
/// (0x2) and BASE1_CLIPMAP (0x4) are skipped (D3DPolyRender inner draw,
|
||||||
/// Ghidra 0x0059d4a0; default on @0x00820e30). acdream suppresses them at
|
/// Ghidra 0x0059d4a0; default on @0x00820e30).
|
||||||
/// BUILD time via Stippling.NoPos in all four extraction paths
|
|
||||||
/// (MeshExtractor.PrepareGfxObjMeshData + PrepareCellStructMeshData
|
|
||||||
/// [moved from ObjectMeshManager in MP1a], CellMesh.Build:44, GfxObjMesh.Build:71).
|
|
||||||
///
|
///
|
||||||
/// These criteria are equivalent ONLY if NoPos ⇔ untextured-surface holds on
|
/// <b>STALE PREMISE, CORRECTED 2026-09-02 (OH2/S1 chunk B).</b> This test's
|
||||||
/// the content. This sweep pins both directions across the populated
|
/// original premise was that acdream suppresses the same geometry at BUILD
|
||||||
/// Holtburg-area landblocks (building shell models + every Environment
|
/// time via <c>Stippling.NoPos</c>, in all four extraction paths, and that
|
||||||
/// CellStruct their cells reference + the door setup parts):
|
/// the NoPos/untextured correlation below is why that build-time shortcut is
|
||||||
/// (a) every NoPos poly's positive surface is untextured (else our skip
|
/// safe. That premise is no longer true for ANY of the four paths:
|
||||||
/// drops something retail draws), and
|
/// - <c>MeshExtractor.PrepareGfxObjMeshData</c> and <c>GfxObjMesh.Build</c>
|
||||||
/// (b) every untextured-surface poly is NoPos (else we draw something
|
/// stopped using NoPos as an emission gate at #426 (2026-08-23) — they
|
||||||
/// retail skips on building/cell passes — the would-be phantom class).
|
/// always emit a polygon's positive side regardless of NoPos, using
|
||||||
|
/// <c>RetailUntexturedSurfacePolicy.IsUntextured(Surface.Type)</c> only
|
||||||
|
/// to pick solid-color vs. textured fill, never to skip a face.
|
||||||
|
/// - <c>MeshExtractor.PrepareCellStructMeshData</c> and the former
|
||||||
|
/// <c>CellMesh.Build</c> (now <c>CellMesh.HasDrawableGeometry</c>, a
|
||||||
|
/// predicate with no render-production role) stopped using NoPos at
|
||||||
|
/// OH2/S1: side candidates come only from <c>CPolygon::sides_type</c>
|
||||||
|
/// (contract §3.4), and built-EnvCell subset admission is the exact
|
||||||
|
/// <c>(Surface.Type & 6) != 0</c> test applied AFTER surface
|
||||||
|
/// resolution (contract §4) — see
|
||||||
|
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
||||||
|
/// and the retired AP-234 register row.
|
||||||
|
///
|
||||||
|
/// The NoPos ⇔ untextured-surface correlation this sweep measures is
|
||||||
|
/// therefore a fact ABOUT THE INSTALLED CONTENT, not a description of how
|
||||||
|
/// acdream currently decides what to draw. It remains worth pinning as a
|
||||||
|
/// content-shape sanity signal (a violation would mean an author left NoPos
|
||||||
|
/// set on a real texture, or textured a "NoPos" aperture fill — either is
|
||||||
|
/// surprising authored content worth knowing about), so the building-model
|
||||||
|
/// half below stays a LOAD-BEARING assertion: it is still an exact,
|
||||||
|
/// evaluable fact about the DAT content (poly.PosSurface resolves directly
|
||||||
|
/// off the GfxObj's own surface array, no EnvCell instance needed). The
|
||||||
|
/// CellStruct half is downgraded to a REPORT ONLY (see
|
||||||
|
/// <see cref="CellPortalPolyMismatches"/> below) — it was already a narrower
|
||||||
|
/// proxy check (NoPos-poly-is-a-portal-poly, not a real per-EnvCell surface
|
||||||
|
/// resolution — a CellStruct alone cannot resolve a surface without a
|
||||||
|
/// specific EnvCell's override array), and its original justification (are
|
||||||
|
/// we skipping only aperture fills, never wall geometry?) no longer applies
|
||||||
|
/// now that CellStruct extraction does not use NoPos to decide any of that.
|
||||||
|
///
|
||||||
|
/// This sweep pins both directions across the populated Holtburg-area
|
||||||
|
/// landblocks (building shell models + every Environment CellStruct their
|
||||||
|
/// cells reference + the door setup parts):
|
||||||
|
/// (a) every NoPos poly's positive surface is untextured (else the old
|
||||||
|
/// build-time-suppression approximation, when it existed, would have
|
||||||
|
/// dropped something retail draws), and
|
||||||
|
/// (b) every untextured-surface poly is NoPos (else the old approximation
|
||||||
|
/// would have drawn something retail's build/cell passes skip).
|
||||||
/// Violations of (b) on PLAIN OBJECT GfxObjs are allowed — retail's bypass
|
/// Violations of (b) on PLAIN OBJECT GfxObjs are allowed — retail's bypass
|
||||||
/// draws solid batches for non-building/non-cell meshes, and so do we.
|
/// draws solid batches for non-building/non-cell meshes, and so do we.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -53,8 +87,13 @@ public sealed class StipplingSurfaceEquivalenceTests
|
||||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
int polysChecked = 0;
|
int polysChecked = 0;
|
||||||
var aViolations = new List<string>(); // NoPos but TEXTURED (our skip would drop a retail-drawn poly)
|
// Building-model (GfxObj) violations only — LOAD-BEARING (Assert.Empty
|
||||||
var bViolations = new List<string>(); // untextured but NOT NoPos (we'd draw what retail skips)
|
// below). See the class docstring: this direction remains a real,
|
||||||
|
// per-polygon-resolvable fact about the DAT content even though
|
||||||
|
// neither GfxObj extraction path (#426) nor either CellStruct path
|
||||||
|
// (OH2/S1) still uses NoPos as a build-time emission gate.
|
||||||
|
var aViolations = new List<string>(); // NoPos but TEXTURED
|
||||||
|
var bViolations = new List<string>(); // untextured but NOT NoPos
|
||||||
|
|
||||||
bool IsTextured(DatReaderWriter.DBObjs.Surface? s) =>
|
bool IsTextured(DatReaderWriter.DBObjs.Surface? s) =>
|
||||||
s is not null &&
|
s is not null &&
|
||||||
|
|
@ -98,6 +137,19 @@ public sealed class StipplingSurfaceEquivalenceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- cell structs referenced by those landblocks' interior cells ----
|
// ---- cell structs referenced by those landblocks' interior cells ----
|
||||||
|
// REPORT ONLY (not asserted — see the class docstring). Direction (a)
|
||||||
|
// can't be evaluated without a specific EnvCell (a CellStruct alone
|
||||||
|
// has no surface array to resolve against), so this was always a
|
||||||
|
// narrower proxy: every NoPos poly must be a portal polygon
|
||||||
|
// (referenced by the struct's Portals list). Its original purpose —
|
||||||
|
// justifying a build-time NoPos-based skip that removes only
|
||||||
|
// aperture fills, never wall geometry — no longer applies, because
|
||||||
|
// neither MeshExtractor.PrepareCellStructMeshData nor
|
||||||
|
// CellMesh.HasDrawableGeometry uses NoPos to decide that any more
|
||||||
|
// (OH2/S1). Kept as a content-shape signal: a mismatch here would
|
||||||
|
// still be surprising authored content worth knowing about, but it
|
||||||
|
// no longer describes a bug in acdream's extraction.
|
||||||
|
var cellPortalPolyMismatches = new List<string>();
|
||||||
foreach (var envId in environments)
|
foreach (var envId in environments)
|
||||||
{
|
{
|
||||||
var env = dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
var env = dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||||
|
|
@ -109,28 +161,23 @@ public sealed class StipplingSurfaceEquivalenceTests
|
||||||
var poly = kv.Value;
|
var poly = kv.Value;
|
||||||
polysChecked++;
|
polysChecked++;
|
||||||
bool noPos = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos);
|
bool noPos = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos);
|
||||||
// CellStruct polys resolve surfaces through the EnvCell's
|
|
||||||
// surface list at runtime; the struct itself stores only the
|
|
||||||
// index. Direction (a) can't be evaluated without a specific
|
|
||||||
// EnvCell, so for structs we pin only the NoPos→portal-poly
|
|
||||||
// correspondence: every NoPos poly must be a portal polygon
|
|
||||||
// (referenced by the struct's Portals list), i.e. our skip
|
|
||||||
// removes only aperture fills, never wall geometry.
|
|
||||||
if (noPos)
|
if (noPos)
|
||||||
{
|
{
|
||||||
bool isPortalPoly = cs.Portals.Any(p => p == kv.Key);
|
bool isPortalPoly = cs.Portals.Any(p => p == kv.Key);
|
||||||
if (!isPortalPoly)
|
if (!isPortalPoly)
|
||||||
aViolations.Add($"env 0x{envId:X8} struct {csId} poly {kv.Key}: NoPos but not a portal poly");
|
cellPortalPolyMismatches.Add($"env 0x{envId:X8} struct {csId} poly {kv.Key}: NoPos but not a portal poly");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_out.WriteLine($"checked {polysChecked} polys across {buildingModels.Count} building models + {environments.Count} environments");
|
_out.WriteLine($"checked {polysChecked} polys across {buildingModels.Count} building models + {environments.Count} environments");
|
||||||
_out.WriteLine($"(a) NoPos-but-textured (skip would drop retail-drawn): {aViolations.Count}");
|
_out.WriteLine($"(a) building NoPos-but-textured (load-bearing): {aViolations.Count}");
|
||||||
foreach (var v in aViolations.Take(20)) _out.WriteLine($" {v}");
|
foreach (var v in aViolations.Take(20)) _out.WriteLine($" {v}");
|
||||||
_out.WriteLine($"(b) untextured-but-not-NoPos on buildings (we'd draw what retail skips): {bViolations.Count}");
|
_out.WriteLine($"(b) building untextured-but-not-NoPos (load-bearing): {bViolations.Count}");
|
||||||
foreach (var v in bViolations.Take(20)) _out.WriteLine($" {v}");
|
foreach (var v in bViolations.Take(20)) _out.WriteLine($" {v}");
|
||||||
|
_out.WriteLine($"(c) cell NoPos-but-not-portal-poly (REPORT ONLY, not asserted): {cellPortalPolyMismatches.Count}");
|
||||||
|
foreach (var v in cellPortalPolyMismatches.Take(20)) _out.WriteLine($" {v}");
|
||||||
|
|
||||||
Assert.Empty(aViolations);
|
Assert.Empty(aViolations);
|
||||||
Assert.Empty(bViolations);
|
Assert.Empty(bViolations);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue