From 05240d2cab82acbd8ab03231602686d2c0826d74 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 16:08:52 +0200 Subject: [PATCH] feat(render) Campaign FW3.3: ShellDrawLiftZ is RETIRED - cells draw at the dat origin Retail draws cell geometry at the dat EnvCell origin verbatim; the 0.02 m lift was our z-fight stand-in (register row AP-32, deleted in this commit). With the walk owning retail draw ORDER under WorldDepthContract Less (first-drawn-wins: DrawBlock terrain-then- objects per cell, DrawCells shells-then-contents), the coplanar tie-breaks the lift compensated for are now resolved the way retail resolves them. Deleted at every site: the PortalVisibilityBuilder const + the drawLiftZ Build parameter and its lifted exit-portal projection branch (gate and drawn geometry now share ONE space); the seal/punch fan lifts (DrawPortalDepthWrite + the walk's DrawWalkPunchFan); the LandblockBuildFactory drawn-cell-transform lift (render and physics share the one verbatim transform). The #130 proof flipped exactly as its own doc predicted: UnliftedGate_LeavesTheStripAtTheDrawnTopEdge is deleted (its premise - gate space != drawn space - no longer exists), and the renamed ExitDoorTopEdge_GateCoversTheDrawnApertureWithinPixelTolerance sweep (147 eye/gaze combos at the Holtburg corner door) passes with both in the same unlifted space (worst plane gap <= 1.2 px, scissor <= 0.15 px - unchanged tolerances). Ten more replay-test call sites swept to the new Build signature. Suites: full Release build 0 warnings; hermetic 6,750/0; the 21 affected InstalledDat replay tests green; Walk conformance 40/1 untouched. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 1 - .../Rendering/PortalVisibilityBuilder.cs | 62 +++------- .../RetailPViewPassExecutor.WalkLeaf.cs | 13 +- .../Rendering/RetailPViewPassExecutor.cs | 3 +- .../Rendering/RetailPViewRenderer.cs | 1 - .../Rendering/Wb/EnvCellLandblockBuild.cs | 10 +- .../Streaming/LandblockBuildFactory.cs | 19 ++- .../Rendering/Issue130DoorwayStripTests.cs | 114 ++---------------- .../Rendering/Issue131SetupProbeTests.cs | 3 +- .../Issue176177FacilityHubFloodReplayTests.cs | 5 +- .../Issue177StairDescentCameraFloodTests.cs | 14 +-- .../Rendering/Issue181VisFlapReplayTests.cs | 6 +- 12 files changed, 55 insertions(+), 196 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a7716216..bafd4099 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -325,7 +325,6 @@ research and is no longer active. | AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 | | ~~AP-28~~ | **RETIRED 2026-08-08 (Campaign A slice A2).** The three picked AL parameters and the gain-driven eviction are both gone. `RetailSoundMixer` now carries the byte-decoded retail curve — `g = dist < 5 ? vol : 25·vol/dist²`, clamped to 1, ONE master multiply, `db = ceil(20·log10 g)`, and a hard −50 dB no-allocate floor (audible radius ≈94.2 m at unity) — with pan as retail's `−15·sin(Δbearing)` in whole decibels and a 5-metre integer deadzone. Every AL source is source-relative with `AL_ROLLOFF_FACTOR = 0` and the global distance model is `None`, so AL contributes no attenuation of its own; the old `InverseDistanceClamped` ref-2 m curve was inverse FIRST power (`2/d`), quieter than retail up close and far louder at range with no cutoff at all. Voice eviction now compares the DAT-authored float priority strictly-less in ring order per `SoundManager::PlaySoundInternal` @ `0x0054FEC0` (the row's old `FUN_00550ad0` citation was wrong — that address is inside an `IntrusiveHashTable` constructor). The residual pan-LAW approximation is AP-173; retail's own `s_bPlaySoundOnlyWhenActive` gate is TS-64. | retired | — | — | `SoundManager::GetAttenuation @ 0x00550020`; `SoundManager::PlaySoundInternal @ 0x00550170` and `@ 0x0054FEC0`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | -| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`ShellDrawLiftZ`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`DrawPortalDepthWrite`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewPassExecutor.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index c0ef5f89..24d9fe37 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -342,31 +342,26 @@ public static class PortalVisibilityBuilder Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}"); } - /// The +Z world lift applied to DRAWN cell shells (z-fighting vs - /// terrain; applied in GameWindow's cell registration). The visibility - /// graph stays in PHYSICS (unlifted) space — feeding the lift into portal - /// planes broke horizontal-portal side tests (#119-residual, f35cb8b). - /// Draw-space consumers of portal polygons (the OutsideView color gate - /// here, the seal/punch depth fans in GameWindow) must apply this lift so - /// they meet the drawn shell's aperture edge — the unlifted gate left a - /// 2 cm background strip under the drawn lintel (#130). - public const float ShellDrawLiftZ = 0.02f; + // Campaign FW3.3 (2026-08-30): ShellDrawLiftZ (the 0.02 m drawn-shell + // lift, register row AP-32) is RETIRED. Retail draws cell geometry at + // the dat EnvCell origin verbatim; the coplanar tie-breaks the lift + // stood in for are now owned by the walk's retail draw ORDER under + // WorldDepthContract's Less (first-drawn-wins — RenderDeviceD3D:: + // DrawBlock terrain-then-objects per cell, PView::DrawCells shells-then- + // contents). With the lift gone, the visibility gate and the drawn + // geometry share ONE space, so the #130 strip mechanism (gate space ≠ + // drawn space) no longer exists. /// Resolve a full cell id to its LoadedCell, or null if not loaded. /// Optional: true if a cell id is in the camera building's cell /// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of /// continuing the in-building BFS. Pass null to treat all reachable cells as in-building. - /// World +Z applied ONLY to the exit-portal projection feeding - /// (a draw-space region; see - /// ). Flood admission, side tests, and CellViews are unaffected. - /// Production passes ; tests replaying visibility semantics pass 0. public static PortalVisibilityFrame Build( LoadedCell cameraCell, Vector3 cameraPos, Func lookup, Matrix4x4 viewProj, Func? buildingMembership = null, - float drawLiftZ = 0f, PortalVisibilityFrame? reuseFrame = null) { var frame = reuseFrame ?? new PortalVisibilityFrame(); @@ -544,38 +539,13 @@ public static class PortalVisibilityBuilder if (portal.OtherCellId == 0xFFFF) { - // Exit portal -> outdoors visible through this (clipped) opening. - // OutsideView gates DRAWN color (terrain/sky/scissor), and the - // shell that rasterizes this aperture draws +drawLiftZ above - // the physics transform — project the region in the SAME - // lifted space or terrain stops a lift-height short of the - // drawn lintel (#130 strip). Flood semantics keep the - // unlifted clippedRegion path above. - int outsideCount; - if (drawLiftZ == 0f) - { - AddRegion(frame.OutsideView, clippedRegion); - outsideCount = clippedRegion.Count; - } - else - { - using PortalVisibilityFrame.ClipRegionScratchLease outsideRegionLease = - frame.RentClipRegionScratch(); - List outsideRegion = outsideRegionLease.Region; - ClipPortalAgainstView( - frame, - poly, - cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ), - viewProj, - currentView.Polygons, - processedCount, - endCount - processedCount, - outsideRegion, - out _); - AddRegion(frame.OutsideView, outsideRegion); - outsideCount = outsideRegion.Count; - } - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideCount} clipVerts={clipVerts}"); + // Exit portal -> outdoors visible through this (clipped) + // opening. FW3.3: shells draw at the dat origin (the + // ShellDrawLiftZ retirement), so the gate and the drawn + // aperture share one space — the unlifted clippedRegion + // IS the draw-space region. + AddRegion(frame.OutsideView, clippedRegion); + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}"); continue; } diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index 47407e10..e70af5cd 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -78,9 +78,8 @@ internal sealed partial class RetailPViewPassExecutor /// The walk's punch-fan turn — DrawPortalPolyInternal /// @0x0059bc90's far-Z punch through PortalDepthMaskRenderer, /// clipped by the pinned view's slice planes (retail - /// building_view @0x0059f3bf). The +ShellDrawLiftZ - /// matches today's DrawPortalDepthWrite and retires with - /// FW3.3. + /// building_view @0x0059f3bf). FW3.3: fans draw at the dat + /// aperture verbatim (the ShellDrawLiftZ retirement). internal void DrawWalkPunchFan( RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly, @@ -100,10 +99,7 @@ internal sealed partial class RetailPViewPassExecutor Span world = stackalloc Vector3[32]; int count = Math.Min(vertices.Length, world.Length); for (int vertex = 0; vertex < count; vertex++) - { world[vertex] = vertices[vertex]; - world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ; - } _portalDepthMask.DrawDepthFan( world[..count], frame.ViewProjection, @@ -134,9 +130,8 @@ internal sealed partial class RetailPViewPassExecutor /// @0x0059f170 draws per cell at its flood turn). /// PortalDepthMaskRenderer.DrawDepthFan /// with forceFarZ, clipped by the pinned view's slice planes -/// (retail building_view @0x0059f3bf). The +ShellDrawLiftZ -/// on the fan verts matches today's DrawPortalDepthWrite and -/// retires with FW3.3. +/// (retail building_view @0x0059f3bf); FW3.3 draws fans at the dat +/// aperture verbatim (the ShellDrawLiftZ retirement). /// / → /// caller-supplied actions (the renderer owns the pass scope and the /// root-flood seal iteration; the adapter only provides the turns). diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 329f34cd..b7feeabf 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -711,10 +711,11 @@ internal sealed partial class RetailPViewPassExecutor : int count = Math.Min(localVertices.Length, world.Length); for (int vertex = 0; vertex < count; vertex++) { + // FW3.3: fans draw at the dat aperture verbatim (the + // ShellDrawLiftZ retirement — shells draw unlifted too). world[vertex] = Vector3.Transform( localVertices[vertex], cell.WorldTransform); - world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ; } _diagnostics.EmitSeamMask( diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index ec49de82..0fcec938 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -148,7 +148,6 @@ public sealed class RetailPViewRenderer ctx.Cells.Find, ctx.ViewProjection, buildingMembership: null, - drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ, reuseFrame: _mainPortalFrameScratch); // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge diff --git a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs index 66dff504..ed2b2c15 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs @@ -353,12 +353,10 @@ public sealed class EnvCellLandblockBuildBuilder // two consumers decode the same dat fields for different needs) // so it stays a direct, auditable port of the FW1 test adapter // rather than reshaping LoadedCell's own list layout around it. - // cellTransform/inverse here are UNLIFTED (this method's - // cellOrigin/cellTransform params are the caller's - // physicsCellOrigin/physicsCellTransform — see - // LandblockBuildFactory.BuildInteriorEntitiesForStreaming, which - // keeps the +0.02 m ShellDrawLiftZ out of this transform) — - // exactly what the walk wants. + // cellTransform/inverse are the dat EnvCell origin verbatim — + // since FW3.3 retired ShellDrawLiftZ, render and physics share + // this one transform (retail draws at the origin; the walk's + // retail draw order owns the coplanar tie-breaks). Walk = WalkCellFactory.FromParsed(envCellId, envCell, cellStruct, cellTransform, inverse), }; } diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs index 14e508cd..9712df86 100644 --- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -532,21 +532,20 @@ public sealed class LandblockBuildFactory // 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. - // Transforms — needed by the portal-visibility cell (unlifted) AND the + // Transforms — needed by the portal-visibility cell 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). + // not just drawable ones. Campaign FW3.3 (2026-08-30): the +0.02 m + // ShellDrawLiftZ render lift is RETIRED (register row AP-32 deleted) — + // retail draws cell geometry at the dat EnvCell origin verbatim, and the + // coplanar tie-breaks the lift stood in for are owned by the walk's + // retail draw order under depth Less. Render and physics now share the + // one verbatim transform. var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + var cellOrigin = physicsCellOrigin; var cellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + var physicsCellTransform = cellTransform; // PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless // of whether CellMesh.Build produced drawable sub-meshes. A portals-only diff --git a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs index e606e75c..22c32fc3 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs @@ -57,7 +57,7 @@ public class Issue130DoorwayStripTests } [Fact] - public void ExitDoorTopEdge_GateCoversLiftedApertureWithinPixelTolerance() + public void ExitDoorTopEdge_GateCoversTheDrawnApertureWithinPixelTolerance() { var datDir = CornerFloodReplayTests.ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } @@ -78,16 +78,15 @@ public class Issue130DoorwayStripTests Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon"); var localPoly = root.PortalPolygons[exitIdx]; - // DRAWN space: the shell that rasterizes the aperture (and the seal fan) - // draws +ShellDrawLiftZ above the physics transform — the gate must be - // compared against the drawn hole, not the physics polygon (#130: the - // unlifted gate left a 2 cm background strip under the drawn lintel). + // Campaign FW3.3: ShellDrawLiftZ is retired — shells, seal fans, and + // the gate all live in the ONE dat space, so the drawn aperture IS + // the physics polygon. This test survives as the coverage proof + // (gate covers the drawn hole within tolerance across the sweep); + // the historical lifted-vs-unlifted strip test's premise (two + // spaces) no longer exists and that test is deleted. var worldPoly = new Vector3[localPoly.Length]; for (int i = 0; i < localPoly.Length; i++) - { worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform); - worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ; - } Vector3 centroid = Vector3.Zero; foreach (var w in worldPoly) centroid += w; @@ -146,7 +145,7 @@ public class Issue130DoorwayStripTests aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W); var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj, - buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + buildingMembership: null); var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); if (asm.OutsideViewSlices.Length == 0) { @@ -203,103 +202,6 @@ public class Issue130DoorwayStripTests $"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}")); } - /// Sensitivity proof + regression documentation: a gate built in - /// PHYSICS space (drawLiftZ 0) against the DRAWN (lifted) aperture shows a - /// multi-pixel strip at a close doorway — the user-visible #130 strip - /// (f35cb8b split the lift out of the visibility transform; the OutsideView - /// kept gating drawn color in unlifted space). If this stops failing-by-gap, - /// the lift is gone and the production drawLiftZ plumbing can go too. - [Fact] - public void UnliftedGate_LeavesTheStripAtTheDrawnTopEdge() - { - var datDir = CornerFloodReplayTests.ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var cells = CornerFloodReplayTests.LoadBuilding(dats); - var root = cells[ExitCellId]; - LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; - - int exitIdx = -1; - for (int i = 0; i < root.Portals.Count; i++) - { - if (root.Portals[i].OtherCellId == 0xFFFF && i < root.PortalPolygons.Count - && root.PortalPolygons[i].Length >= 3) - { exitIdx = i; break; } - } - Assert.True(exitIdx >= 0); - - var localPoly = root.PortalPolygons[exitIdx]; - var worldPoly = new Vector3[localPoly.Length]; - Vector3 centroid = Vector3.Zero; - for (int i = 0; i < localPoly.Length; i++) - { - worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform); - worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ; // drawn space - centroid += worldPoly[i]; - } - centroid /= worldPoly.Length; - - var plane = root.ClipPlanes[exitIdx]; - var worldNormal = Vector3.TransformNormal(plane.Normal, root.WorldTransform); - var cellCenterWorld = Vector3.Transform( - (root.LocalBoundsMin + root.LocalBoundsMax) * 0.5f, root.WorldTransform); - if (Vector3.Dot(worldNormal, cellCenterWorld - centroid) < 0) - worldNormal = -worldNormal; - worldNormal = Vector3.Normalize(worldNormal); - - // d=2.4 m, eye low (0.9 m above the opening's base), gaze at the - // centroid — the main sweep's clean case, where the aperture top edge - // projects ON SCREEN (y≈0.79; a closer/higher eye pushes the lintel - // past the screen top and the seam becomes unmeasurable). - var eye = centroid + worldNormal * 2.4f; - eye.Z = centroid.Z - 1.0f + 0.9f; - var viewProj = ViewProjFor(eye, centroid); - - var clip = new Vector4[worldPoly.Length]; - for (int i = 0; i < worldPoly.Length; i++) - clip[i] = Vector4.Transform(new Vector4(worldPoly[i], 1f), viewProj); - var aperture = new Vector2[clip.Length]; - for (int i = 0; i < clip.Length; i++) - aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W); - - var pvUnlifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj); // drawLiftZ 0 - var asmUnlifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvUnlifted); - Assert.True(asmUnlifted.OutsideViewSlices.Length > 0); - (float unliftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmUnlifted.OutsideViewSlices, 1920, 1080); - - var pvLifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj, - buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); - var asmLifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvLifted); - Assert.True(asmLifted.OutsideViewSlices.Length > 0); - (float liftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmLifted.OutsideViewSlices, 1920, 1080); - - _out.WriteLine(FormattableString.Invariant( - $"top-edge gap vs the DRAWN aperture at d=2.4 m: unliftedGate={unliftedGapPx:F2}px liftedGate={liftedGapPx:F2}px")); - var dbg = new System.Text.StringBuilder(" aperture(LIFTED):"); - foreach (var v in aperture) dbg.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})")); - _out.WriteLine(dbg.ToString()); - foreach (var poly in pvUnlifted.OutsideView.Polygons) - { - var sb = new System.Text.StringBuilder(" unliftedGatePoly:"); - foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})")); - _out.WriteLine(sb.ToString()); - } - foreach (var poly in pvLifted.OutsideView.Polygons) - { - var sb = new System.Text.StringBuilder(" liftedGatePoly:"); - foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})")); - _out.WriteLine(sb.ToString()); - } - - // The strip the user saw: physics-space gate vs drawn hole, several px. - Assert.True(unliftedGapPx > 2.0f, FormattableString.Invariant( - $"expected the unlifted gate to show the strip (>2px), got {unliftedGapPx:F2}px")); - // The fix: a gate in drawn space covers the drawn hole. - Assert.True(liftedGapPx <= 1.2f, FormattableString.Invariant( - $"lifted gate still under-covers by {liftedGapPx:F2}px")); - } - private static string DescribePolys(CellView view) { var parts = new List(); diff --git a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs index 6a544e6d..12012f04 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs @@ -48,8 +48,7 @@ public class Issue131SetupProbeTests var root = cells[0xA9B40171u]; var pv = AcDream.App.Rendering.PortalVisibilityBuilder.Build( root, eye, Lookup, viewProj, - buildingMembership: null, - drawLiftZ: AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + buildingMembership: null); _out.WriteLine(FormattableString.Invariant( $"main flood={pv.OrderedVisibleCells.Count} outPolys={pv.OutsideView.Polygons.Count}")); diff --git a/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs index 573ec736..3412fc32 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs @@ -19,7 +19,7 @@ namespace AcDream.App.Tests.Rendering; /// where its geometry was; stair cells failing admission = the pop). /// /// Production-matched inputs: Build(root, eye, lookup, viewProj, -/// buildingMembership: null, drawLiftZ: ShellDrawLiftZ) — the drawLiftZ +/// buildingMembership: null) — the production call shape (FW3.3 retired drawLiftZ) /// mirrors RetailPViewRenderer.DrawInside. /// /// Scenarios: @@ -54,8 +54,7 @@ public class Issue176177FacilityHubFloodReplayTests Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; var frame = PortalVisibilityBuilder.Build( cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir), - buildingMembership: null, - drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + buildingMembership: null); var result = new List(frame.OrderedVisibleCells); result.Sort(); return result; diff --git a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs index 6262ee7f..32435cbd 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs @@ -207,7 +207,7 @@ public class Issue177StairDescentCameraFloodTests string admit = "?"; if (viewer != 0u && renderCells.TryGetValue(viewer, out var rc)) { - var f = PortalVisibilityBuilder.Build(rc, eye, lookup, cam.View * cam.Projection, null, PortalVisibilityBuilder.ShellDrawLiftZ); + var f = PortalVisibilityBuilder.Build(rc, eye, lookup, cam.View * cam.Projection, null); var s = f.OrderedVisibleCells; admit = string.Concat(stair.Select(low => (s.Contains(FacilityHub | low) ? "1" : "0"))); } @@ -259,7 +259,7 @@ public class Issue177StairDescentCameraFloodTests for (int i = 0; i < 140; i++) cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A); uint viewer = cam.ViewerCellId; if (viewer == 0u || !renderCells.TryGetValue(viewer, out var rc)) continue; - var f = PortalVisibilityBuilder.Build(rc, cam.Position, lookup, cam.View * cam.Projection, null, PortalVisibilityBuilder.ShellDrawLiftZ); + var f = PortalVisibilityBuilder.Build(rc, cam.Position, lookup, cam.View * cam.Projection, null); int c = f.OrderedVisibleCells.Count; sum += c; n++; if (c > maxCells) { maxCells = c; bestViewer = viewer; bestSet = CellSet(f.OrderedVisibleCells.OrderBy(v => v)); } } @@ -326,7 +326,7 @@ public class Issue177StairDescentCameraFloodTests float d = PortalPlaneDistance(root, upIdx, eye); var frame = PortalVisibilityBuilder.Build( root, eye, lookup, ViewProj(eye, gaze), - buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + buildingMembership: null); var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList(); bool up = vis.Contains(upperId); string admit = up ? "ADMITTED" : "DROPPED"; @@ -339,8 +339,8 @@ public class Issue177StairDescentCameraFloodTests // spiral, root 0x01C8, portals off-screen/sliver, camera NOT collision-jammed). // Kept as a mechanism pin: an eye exactly in the ceiling-portal plane drops the upper // cell (edge-on → <3 clip), an eye 0.4 m off admits it via the normal clip. - var fEdge = PortalVisibilityBuilder.Build(root, eyeEdgeOn, lookup, ViewProj(eyeEdgeOn, gaze), null, PortalVisibilityBuilder.ShellDrawLiftZ); - var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null, PortalVisibilityBuilder.ShellDrawLiftZ); + var fEdge = PortalVisibilityBuilder.Build(root, eyeEdgeOn, lookup, ViewProj(eyeEdgeOn, gaze), null); + var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null); Assert.DoesNotContain(upperId, fEdge.OrderedVisibleCells); // edge-on drops (mechanism) Assert.Contains(upperId, fOff.OrderedVisibleCells); // off-plane admits (control) } @@ -438,7 +438,7 @@ public class Issue177StairDescentCameraFloodTests var vp = cam.View * cam.Projection; if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell)) { - var f = PortalVisibilityBuilder.Build(rootCell, eye, lookup, vp, null, PortalVisibilityBuilder.ShellDrawLiftZ); + var f = PortalVisibilityBuilder.Build(rootCell, eye, lookup, vp, null); var vis = f.OrderedVisibleCells.OrderBy(v => v).ToList(); string flags = $"0178={(vis.Contains(FacilityHub | 0x0178u) ? "Y" : "-")} 0182={(vis.Contains(FacilityHub | 0x0182u) ? "Y" : "-")} 0183={(vis.Contains(FacilityHub | 0x0183u) ? "Y" : "-")} 0181={(vis.Contains(FacilityHub | 0x0181u) ? "Y" : "-")}"; _out.WriteLine(FormattableString.Invariant( @@ -519,7 +519,7 @@ public class Issue177StairDescentCameraFloodTests { var frame = PortalVisibilityBuilder.Build( rootCell, eye, lookup, viewProj, - buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + buildingMembership: null); var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList(); floodStr = $"{vis.Count,2} [{CellSet(vis)}]"; bool ramp = vis.Contains(FacilityHub | 0x0182u); diff --git a/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs index dc0a69dd..13f4c827 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs @@ -46,8 +46,7 @@ public class Issue181VisFlapReplayTests Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; var frame = PortalVisibilityBuilder.Build( cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir), - buildingMembership: null, - drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + buildingMembership: null); var result = new List(frame.OrderedVisibleCells); result.Sort(); return result; @@ -88,8 +87,7 @@ public class Issue181VisFlapReplayTests { var frame = PortalVisibilityBuilder.Build( cells[LiveRoot], eye, lookup, ViewProjFor(eye, gaze), - buildingMembership: null, - drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + buildingMembership: null); if (!frame.CellViews.TryGetValue(flapper, out var view) || view.IsEmpty) {