Revert "Campaign V slice V4a" - it lost world multisampling

This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 18:27:52 +02:00
parent ceec3bc440
commit 9aaf97e785
334 changed files with 3841 additions and 3661 deletions

View file

@ -1,4 +1,4 @@
using DatReaderWriter;
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.Streaming;
@ -14,7 +14,7 @@ namespace AcDream.App.Streaming;
/// <c>CObjCell::init_objects</c> (0x0052B420).
/// See <c>docs/architecture/worldbuilder-inventory.md</c>.
/// </summary>
internal sealed class LandblockBuildFactory
public sealed class LandblockBuildFactory
{
private readonly IDatReaderWriter _dats;
private readonly IPreparedCollisionSource _preparedCollisions;
@ -51,7 +51,7 @@ internal sealed class LandblockBuildFactory
///
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
/// LandBlockInfo + scenery + interior hydration. They return only the
/// LandBlock heightmap dat record + an empty entity list — enough for
/// LandBlock heightmap dat record + an empty entity list enough for
/// terrain-mesh build on the next phase. Near-tier loads run the full
/// path. This replaces Bug A's post-load entity strip in
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
@ -70,7 +70,7 @@ internal sealed class LandblockBuildFactory
// gate through mesh/bounds hydration prevents another consumer from
// interleaving reader cursor/cache state with this build.
// tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock
// contention signal — large only when the render thread is hammering the
// contention signal large only when the render thread is hammering the
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
// cost). Identical work in both branches; the probe branch only adds the
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
@ -123,7 +123,7 @@ internal sealed class LandblockBuildFactory
{
uint landblockId = request.LandblockId;
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
// ISSUE #54: far-tier early-out heightmap only, empty entities.
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
// worker-thread cost per far-tier LB from ~tens of ms to a single
@ -203,7 +203,7 @@ internal sealed class LandblockBuildFactory
}
/// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls.
/// landblock on the worker thread. Pure CPU no GL calls.
///
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
@ -305,7 +305,7 @@ internal sealed class LandblockBuildFactory
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
// — it uses the same AC2D render split-direction formula the
// it uses the same AC2D render split-direction formula the
// TerrainModernRenderer uses for the visible terrain mesh. This
// guarantees trees are placed on the SAME Z height the player
// walks on. If physics hasn't registered this landblock yet,
@ -313,14 +313,14 @@ internal sealed class LandblockBuildFactory
var worldPx = localX + lbOffset.X;
var worldPy = localY + lbOffset.Y;
// FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
// landblock's OWN heightmap the same triangle-aware Z the player walks on
// (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48),
// scoped to the landblock being built. The former global
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
// build time this landblock is NOT registered in physics yet, so that query
// could only return null (→ this same own-heightmap) or a STALE neighbor's
// height — the previous location's terrain before the full old-window
// recenter retirement converges — planting scenery at the old location's
// could only return null ( this same own-heightmap) or a STALE neighbor's
// height the previous location's terrain before the full old-window
// recenter retirement converges planting scenery at the old location's
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
// case, so the global query is removed (also drops its per-spawn cost).
@ -335,7 +335,7 @@ internal sealed class LandblockBuildFactory
if (_dumpSceneryZ)
{
// groundZ now always comes from THIS landblock's own heightmap (the
// global physics query was removed — see the trees-in-sky fix above).
// global physics query was removed see the trees-in-sky fix above).
string source = "heightmap";
foreach (var mr in meshRefs)
{
@ -422,7 +422,7 @@ internal sealed class LandblockBuildFactory
/// <summary>
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU no GL calls.
///
/// Portal cells and drawable shell placements are accumulated in the
/// transaction-local <paramref name="envCellBuild"/>. The render thread
@ -448,28 +448,28 @@ internal sealed class LandblockBuildFactory
(lbY - origin.CenterY) * 192f,
0f);
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
// Per-landblock id namespace see AcDream.Core.World.InteriorEntityIdAllocator
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
// landblock stabs (0xC0000000+, ids from LandblockLoader).
//
// #119 ROOT-CAUSE FIX (2026-06-11): this used to be
// `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
// resolves to 0x40YYFF00 the landblock X byte DISCARDED. Every landblock in a
// map Y-row produced the same id base, so interior statics collided across
// landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral
// staircase, both 0x40B3FF09). The Tier-1 classification cache then served one
// entity's batches to the other (the cache hint at bucket-draw time was the
// player's landblock, identical for both) — the session-sticky "broken stairs +
// player's landblock, identical for both) the session-sticky "broken stairs +
// water barrel".
//
// #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow
// #190 (2026-07-09): the fix above LEFT a documented residual "counter overflow
// past 0xFF still bleeds into the lbY byte." That residual manifested for real:
// the Town Network hub (205 cells, one landblock) reached 277 interior entities
// after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT
// landblock's Y-byte (entity script/particle tracking is keyed on entity.Id
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
// the fountain's water-spray script silently stopped firing). Widened the
// counter budget 8â†12 bits (256â†4096); see InteriorEntityIdAllocator's doc for
// counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for
// why this is safe (nothing decodes X/Y back out of an entity id).
uint interiorLbX = (landblockId >> 24) & 0xFFu;
uint interiorLbY = (landblockId >> 16) & 0xFFu;
@ -484,7 +484,7 @@ internal sealed class LandblockBuildFactory
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
// every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and
// MUST exist in the cell dat — a null here is always a read anomaly.
// MUST exist in the cell dat a null here is always a read anomaly.
Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})");
continue;
}
@ -498,7 +498,7 @@ internal sealed class LandblockBuildFactory
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
// a null Environment means this cell's WALLS are silently never
// registered while its static objects still draw — the exact
// registered while its static objects still draw the exact
// white-walls geometry signature.
Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered");
}
@ -510,14 +510,14 @@ internal sealed class LandblockBuildFactory
// 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
// as WorldEntity records below they have real GfxObj MeshRefs that work
// fine; EnvCellRenderer receives only the completed shell transaction.
// Transforms — needed by the portal-visibility cell (unlifted) AND the
// Transforms needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
// shared with every draw-space consumer of portal polygons (OutsideView
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
// gate, seal/punch fans) PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
@ -532,9 +532,9 @@ internal sealed class LandblockBuildFactory
// of whether CellMesh.Build produced drawable sub-meshes. 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
// to the cells beyond otherwise the flood lookup-misses the unregistered
// neighbour and the grey clear shows through the opening (#133: ramp
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
// before the flood runs; the cell-build transaction reads portals, NOT
@ -558,7 +558,7 @@ internal sealed class LandblockBuildFactory
foreach (var stab in envCell.StaticObjects)
{
// #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY-
// targeted stabs. This is the MOMENT MeshRefs are constructed —
// targeted stabs. This is the MOMENT MeshRefs are constructed
// a degraded dat read here (setup null / placement frames short /
// part GfxObj null) permanently corrupts the entity (H-A), and
// nothing downstream ever rebuilds it. Inert when the set is empty.
@ -568,7 +568,7 @@ internal sealed class LandblockBuildFactory
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it only the
// WorldBuilder editor shows it at the origin. acdream's render path came from
// WB (no distance LOD), so without this skip it draws the marker forever (the
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
@ -581,7 +581,7 @@ internal sealed class LandblockBuildFactory
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
// #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a
// runtime-hidden marker (#136) flattens to zero mesh refs even though its
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
// Setup carries real Lights a "light attach point" fixture (e.g. the Town
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
// the entity that otherwise carries those lights to the static
@ -618,7 +618,7 @@ internal sealed class LandblockBuildFactory
{
// #136: skip an editor-only marker PART (retail hides it at runtime
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
// part GfxObj 0x010028CA is such a marker — skipping it empties
// part GfxObj 0x010028CA is such a marker skipping it empties
// meshRefs and the whole stab drops below.
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
continue;
@ -652,7 +652,7 @@ internal sealed class LandblockBuildFactory
// Stabs inside EnvCells are already in landblock-local coordinates
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
// be wrong — see Phase 2d comment in the pre-streaming preload.
// be wrong see Phase 2d comment in the pre-streaming preload.
var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation;