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
|
|
@ -2110,6 +2110,18 @@ namespace AcDream.App.Rendering.Wb
|
|||
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 batch in batches)
|
||||
|
|
|
|||
|
|
@ -526,9 +526,11 @@ public sealed class LandblockBuildFactory
|
|||
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
|
||||
{
|
||||
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
|
||||
// WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
|
||||
// drawable-geometry predicate; the actual shell placement is now owned
|
||||
// by this streaming job's EnvCellLandblockBuild transaction.
|
||||
// WorldEntities with fake MeshRefs. CellMesh.HasDrawableGeometry (OH2/S1
|
||||
// chunk B, retired CellMesh.Build's render-production role and AP-234's
|
||||
// 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
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// fine; EnvCellRenderer receives only the completed shell transaction.
|
||||
|
|
@ -548,7 +550,7 @@ public sealed class LandblockBuildFactory
|
|||
var physicsCellTransform = cellTransform;
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
|
@ -560,7 +562,8 @@ public sealed class LandblockBuildFactory
|
|||
// 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
|
||||
// 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(
|
||||
envCellId,
|
||||
envCell,
|
||||
|
|
@ -569,7 +572,7 @@ public sealed class LandblockBuildFactory
|
|||
physicsCellTransform,
|
||||
cellOrigin,
|
||||
cellTransform,
|
||||
hasDrawableGeometry: cellSubMeshes.Count > 0);
|
||||
hasDrawableGeometry: hasDrawableGeometry);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,141 +1,104 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Terrain;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Builds renderable sub-meshes from an EnvCell's room geometry (walls,
|
||||
/// floors, ceilings). The geometry lives in the linked Environment dat:
|
||||
/// EnvCell.EnvironmentId → Environment → Cells[CellStructure] → CellStruct.
|
||||
/// This mirrors GfxObjMesh.Build but reads surfaces from EnvCell.Surfaces
|
||||
/// (not from the CellStruct itself) and uses the same fan-triangulation
|
||||
/// and per-surface deduplication pattern.
|
||||
/// OH2/S1 chunk B (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
||||
/// §9 item 5): retires this type's former render-production role. The old
|
||||
/// <c>Build</c> method carried its own NoPos-based "skip untextured cell
|
||||
/// geometry" approximation (AP-234, retired) — a second, divergent
|
||||
/// interpretation of the retail CellStruct surface/subset construction
|
||||
/// 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>
|
||||
public static class CellMesh
|
||||
{
|
||||
/// <summary>
|
||||
/// Walk a CellStruct's polygons and produce one <see cref="GfxObjSubMesh"/>
|
||||
/// per referenced Surface. Surfaces are resolved from <paramref name="envCell"/>.Surfaces
|
||||
/// (OR'd with 0x08000000 to form the full dat id). Polygons are triangulated as fans.
|
||||
/// True iff at least one of this CellStruct's polygon construction
|
||||
/// candidates (<see cref="CellStructSideCandidates.GetCandidates"/>,
|
||||
/// 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>
|
||||
/// <param name="envCell">The EnvCell that owns the surface list.</param>
|
||||
/// <param name="cellStruct">The CellStruct containing the polygon + vertex geometry.</param>
|
||||
/// <param name="dats">
|
||||
/// Optional dat collection used to read Surface.Type flags and set
|
||||
/// <see cref="GfxObjSubMesh.Translucency"/>. When null (e.g. offline tests)
|
||||
/// all sub-meshes default to <see cref="TranslucencyKind.Opaque"/>.
|
||||
/// <remarks>
|
||||
/// Side candidates come only from <c>CPolygon::sides_type</c>
|
||||
/// (contract §3.4); <c>NoPos</c>/<c>NoNeg</c> play no role in this
|
||||
/// predicate — they mean "this side's UV-index array is absent"
|
||||
/// (contract §2.3/§3.5), which cannot change whether a subset draws,
|
||||
/// 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>
|
||||
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.
|
||||
var perSurface = new Dictionary<uint, (List<Vertex> Vertices, List<uint> Indices, Dictionary<(int pos, int uv), uint> Dedupe)>();
|
||||
// One resolve per distinct surface slot, not per polygon: a slot's
|
||||
// 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)
|
||||
continue; // degenerate polygon
|
||||
|
||||
// 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))
|
||||
bool textured = false;
|
||||
if (slot >= 0 && slot < envCell.Surfaces.Count)
|
||||
{
|
||||
bucket = (new List<Vertex>(), new List<uint>(), new Dictionary<(int, int), uint>());
|
||||
perSurface[surfaceId] = bucket;
|
||||
uint surfaceId = 0x08000000u | envCell.Surfaces[slot];
|
||||
if (dats.Get<Surface>(surfaceId) is { } surface)
|
||||
textured = !RetailUntexturedSurfacePolicy.IsUntextured(surface.Type);
|
||||
}
|
||||
slotIsTextured[slot] = textured;
|
||||
return textured;
|
||||
}
|
||||
|
||||
// Collect output vertex indices for this polygon.
|
||||
var polyOut = new List<uint>(poly.VertexIds.Count);
|
||||
bool skipPoly = false;
|
||||
foreach (var poly in cellStruct.Polygons.Values)
|
||||
{
|
||||
// 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];
|
||||
int uvIdx = i < poly.PosUVIndices.Count ? poly.PosUVIndices[i] : 0;
|
||||
short surfaceIdxRaw = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive
|
||||
? poly.PosSurface
|
||||
: poly.NegSurface;
|
||||
if (surfaceIdxRaw < 0) continue;
|
||||
|
||||
if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)posIdx, out var sw))
|
||||
{
|
||||
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]);
|
||||
if (SlotIsTextured(surfaceIdxRaw))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit one sub-mesh per surface.
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,12 +55,19 @@ public static class RetailUntexturedSurfacePolicy
|
|||
/// <remarks>
|
||||
/// EnvCell interiors are retail's OTHER "skip untextured" case
|
||||
/// (<c>RenderDeviceD3D::DrawEnvCell</c> @0x0059f170 calls
|
||||
/// <c>DrawMesh(..., arg4: 1)</c>), but EnvCell/CellStruct geometry never
|
||||
/// reaches this predicate — it draws through <c>EnvCellRenderer</c> /
|
||||
/// <c>MeshExtractor.PrepareCellStructMeshData</c>, which keeps its own
|
||||
/// NoPos-based approximation of the same rule (see
|
||||
/// <c>docs/architecture/retail-divergence-register.md</c> AP-234 and
|
||||
/// <c>CellMesh.cs</c>'s matching gate).
|
||||
/// <c>DrawMesh(..., arg4: 1)</c>). As of OH2/S1 (2026-09-02,
|
||||
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md),
|
||||
/// EnvCell/CellStruct geometry reaches this SAME predicate directly, with no
|
||||
/// proxy: <c>MeshExtractor.PrepareCellStructMeshData</c> resolves each
|
||||
/// candidate's source surface slot FIRST, then calls
|
||||
/// <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>
|
||||
/// ONE shared predicate for <c>WbDrawDispatcher</c>'s classic classifier
|
||||
/// (<c>ClassifyBatches</c>), packed classifier (<c>ClassifyPackedBatches</c>),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue