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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 16:08:52 +02:00
parent 1955a0d5f1
commit 05240d2cab
12 changed files with 55 additions and 196 deletions

View file

@ -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-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-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-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-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-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 | | 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 |

View file

@ -342,31 +342,26 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}"); Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
} }
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs // Campaign FW3.3 (2026-08-30): ShellDrawLiftZ (the 0.02 m drawn-shell
/// terrain; applied in GameWindow's cell registration). The visibility // lift, register row AP-32) is RETIRED. Retail draws cell geometry at
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal // the dat EnvCell origin verbatim; the coplanar tie-breaks the lift
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b). // stood in for are now owned by the walk's retail draw ORDER under
/// Draw-space consumers of portal polygons (the OutsideView color gate // WorldDepthContract's Less (first-drawn-wins — RenderDeviceD3D::
/// here, the seal/punch depth fans in GameWindow) must apply this lift so // DrawBlock terrain-then-objects per cell, PView::DrawCells shells-then-
/// they meet the drawn shell's aperture edge — the unlifted gate left a // contents). With the lift gone, the visibility gate and the drawn
/// 2 cm background strip under the drawn lintel (#130).</summary> // geometry share ONE space, so the #130 strip mechanism (gate space ≠
public const float ShellDrawLiftZ = 0.02f; // drawn space) no longer exists.
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param> /// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell /// <param name="buildingMembership">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 /// 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.</param> /// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
public static PortalVisibilityFrame Build( public static PortalVisibilityFrame Build(
LoadedCell cameraCell, LoadedCell cameraCell,
Vector3 cameraPos, Vector3 cameraPos,
Func<uint, LoadedCell?> lookup, Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj, Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null, Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f,
PortalVisibilityFrame? reuseFrame = null) PortalVisibilityFrame? reuseFrame = null)
{ {
var frame = reuseFrame ?? new PortalVisibilityFrame(); var frame = reuseFrame ?? new PortalVisibilityFrame();
@ -544,38 +539,13 @@ public static class PortalVisibilityBuilder
if (portal.OtherCellId == 0xFFFF) if (portal.OtherCellId == 0xFFFF)
{ {
// Exit portal -> outdoors visible through this (clipped) opening. // Exit portal -> outdoors visible through this (clipped)
// OutsideView gates DRAWN color (terrain/sky/scissor), and the // opening. FW3.3: shells draw at the dat origin (the
// shell that rasterizes this aperture draws +drawLiftZ above // ShellDrawLiftZ retirement), so the gate and the drawn
// the physics transform — project the region in the SAME // aperture share one space — the unlifted clippedRegion
// lifted space or terrain stops a lift-height short of the // IS the draw-space region.
// drawn lintel (#130 strip). Flood semantics keep the AddRegion(frame.OutsideView, clippedRegion);
// unlifted clippedRegion path above. trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
int outsideCount;
if (drawLiftZ == 0f)
{
AddRegion(frame.OutsideView, clippedRegion);
outsideCount = clippedRegion.Count;
}
else
{
using PortalVisibilityFrame.ClipRegionScratchLease outsideRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> 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}");
continue; continue;
} }

View file

@ -78,9 +78,8 @@ internal sealed partial class RetailPViewPassExecutor
/// <summary>The walk's punch-fan turn — <c>DrawPortalPolyInternal</c> /// <summary>The walk's punch-fan turn — <c>DrawPortalPolyInternal</c>
/// @0x0059bc90's far-Z punch through <c>PortalDepthMaskRenderer</c>, /// @0x0059bc90's far-Z punch through <c>PortalDepthMaskRenderer</c>,
/// clipped by the pinned view's slice planes (retail /// clipped by the pinned view's slice planes (retail
/// <c>building_view</c> @0x0059f3bf). The +<c>ShellDrawLiftZ</c> /// <c>building_view</c> @0x0059f3bf). FW3.3: fans draw at the dat
/// matches today's <c>DrawPortalDepthWrite</c> and retires with /// aperture verbatim (the ShellDrawLiftZ retirement).</summary>
/// FW3.3.</summary>
internal void DrawWalkPunchFan( internal void DrawWalkPunchFan(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
@ -100,10 +99,7 @@ internal sealed partial class RetailPViewPassExecutor
Span<Vector3> world = stackalloc Vector3[32]; Span<Vector3> world = stackalloc Vector3[32];
int count = Math.Min(vertices.Length, world.Length); int count = Math.Min(vertices.Length, world.Length);
for (int vertex = 0; vertex < count; vertex++) for (int vertex = 0; vertex < count; vertex++)
{
world[vertex] = vertices[vertex]; world[vertex] = vertices[vertex];
world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ;
}
_portalDepthMask.DrawDepthFan( _portalDepthMask.DrawDepthFan(
world[..count], world[..count],
frame.ViewProjection, frame.ViewProjection,
@ -134,9 +130,8 @@ internal sealed partial class RetailPViewPassExecutor
/// @0x0059f170 draws per cell at its flood turn).</item> /// @0x0059f170 draws per cell at its flood turn).</item>
/// <item><see cref="DrawPunchFan"/> → <c>PortalDepthMaskRenderer.DrawDepthFan</c> /// <item><see cref="DrawPunchFan"/> → <c>PortalDepthMaskRenderer.DrawDepthFan</c>
/// with <c>forceFarZ</c>, clipped by the pinned view's slice planes /// with <c>forceFarZ</c>, clipped by the pinned view's slice planes
/// (retail <c>building_view</c> @0x0059f3bf). The +<c>ShellDrawLiftZ</c> /// (retail <c>building_view</c> @0x0059f3bf); FW3.3 draws fans at the dat
/// on the fan verts matches today's <c>DrawPortalDepthWrite</c> and /// aperture verbatim (the ShellDrawLiftZ retirement).</item>
/// retires with FW3.3.</item>
/// <item><see cref="ClearInteriorDepth"/>/<see cref="DrawExitSeals"/> → /// <item><see cref="ClearInteriorDepth"/>/<see cref="DrawExitSeals"/> →
/// caller-supplied actions (the renderer owns the pass scope and the /// caller-supplied actions (the renderer owns the pass scope and the
/// root-flood seal iteration; the adapter only provides the turns).</item> /// root-flood seal iteration; the adapter only provides the turns).</item>

View file

@ -711,10 +711,11 @@ internal sealed partial class RetailPViewPassExecutor :
int count = Math.Min(localVertices.Length, world.Length); int count = Math.Min(localVertices.Length, world.Length);
for (int vertex = 0; vertex < count; vertex++) 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( world[vertex] = Vector3.Transform(
localVertices[vertex], localVertices[vertex],
cell.WorldTransform); cell.WorldTransform);
world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ;
} }
_diagnostics.EmitSeamMask( _diagnostics.EmitSeamMask(

View file

@ -148,7 +148,6 @@ public sealed class RetailPViewRenderer
ctx.Cells.Find, ctx.Cells.Find,
ctx.ViewProjection, ctx.ViewProjection,
buildingMembership: null, buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
reuseFrame: _mainPortalFrameScratch); reuseFrame: _mainPortalFrameScratch);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge

View file

@ -353,12 +353,10 @@ public sealed class EnvCellLandblockBuildBuilder
// two consumers decode the same dat fields for different needs) // two consumers decode the same dat fields for different needs)
// so it stays a direct, auditable port of the FW1 test adapter // so it stays a direct, auditable port of the FW1 test adapter
// rather than reshaping LoadedCell's own list layout around it. // rather than reshaping LoadedCell's own list layout around it.
// cellTransform/inverse here are UNLIFTED (this method's // cellTransform/inverse are the dat EnvCell origin verbatim —
// cellOrigin/cellTransform params are the caller's // since FW3.3 retired ShellDrawLiftZ, render and physics share
// physicsCellOrigin/physicsCellTransform — see // this one transform (retail draws at the origin; the walk's
// LandblockBuildFactory.BuildInteriorEntitiesForStreaming, which // retail draw order owns the coplanar tie-breaks).
// keeps the +0.02 m ShellDrawLiftZ out of this transform) —
// exactly what the walk wants.
Walk = WalkCellFactory.FromParsed(envCellId, envCell, cellStruct, cellTransform, inverse), Walk = WalkCellFactory.FromParsed(envCellId, envCell, cellStruct, cellTransform, inverse),
}; };
} }

View file

@ -532,21 +532,20 @@ public sealed class LandblockBuildFactory
// 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.
// 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, // render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail // not just drawable ones. Campaign FW3.3 (2026-08-30): the +0.02 m
// BSP contact planes use the EnvCell origin verbatim. The lift constant is // ShellDrawLiftZ render lift is RETIRED (register row AP-32 deleted) —
// shared with every draw-space consumer of portal polygons (OutsideView // retail draws cell geometry at the dat EnvCell origin verbatim, and the
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130). // 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 physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( var cellOrigin = physicsCellOrigin;
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform = var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform = var physicsCellTransform = cellTransform;
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
// 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.Build produced drawable sub-meshes. A portals-only

View file

@ -57,7 +57,7 @@ public class Issue130DoorwayStripTests
} }
[Fact] [Fact]
public void ExitDoorTopEdge_GateCoversLiftedApertureWithinPixelTolerance() public void ExitDoorTopEdge_GateCoversTheDrawnApertureWithinPixelTolerance()
{ {
var datDir = CornerFloodReplayTests.ResolveDatDir(); 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."); } 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"); Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon");
var localPoly = root.PortalPolygons[exitIdx]; var localPoly = root.PortalPolygons[exitIdx];
// DRAWN space: the shell that rasterizes the aperture (and the seal fan) // Campaign FW3.3: ShellDrawLiftZ is retired — shells, seal fans, and
// draws +ShellDrawLiftZ above the physics transform — the gate must be // the gate all live in the ONE dat space, so the drawn aperture IS
// compared against the drawn hole, not the physics polygon (#130: the // the physics polygon. This test survives as the coverage proof
// unlifted gate left a 2 cm background strip under the drawn lintel). // (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]; var worldPoly = new Vector3[localPoly.Length];
for (int i = 0; i < localPoly.Length; i++) for (int i = 0; i < localPoly.Length; i++)
{
worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform); worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform);
worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ;
}
Vector3 centroid = Vector3.Zero; Vector3 centroid = Vector3.Zero;
foreach (var w in worldPoly) centroid += w; 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); aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W);
var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj, var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj,
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); buildingMembership: null);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
if (asm.OutsideViewSlices.Length == 0) 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}")); $"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}"));
} }
/// <summary>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.</summary>
[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) private static string DescribePolys(CellView view)
{ {
var parts = new List<string>(); var parts = new List<string>();

View file

@ -48,8 +48,7 @@ public class Issue131SetupProbeTests
var root = cells[0xA9B40171u]; var root = cells[0xA9B40171u];
var pv = AcDream.App.Rendering.PortalVisibilityBuilder.Build( var pv = AcDream.App.Rendering.PortalVisibilityBuilder.Build(
root, eye, Lookup, viewProj, root, eye, Lookup, viewProj,
buildingMembership: null, buildingMembership: null);
drawLiftZ: AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
_out.WriteLine(FormattableString.Invariant( _out.WriteLine(FormattableString.Invariant(
$"main flood={pv.OrderedVisibleCells.Count} outPolys={pv.OutsideView.Polygons.Count}")); $"main flood={pv.OrderedVisibleCells.Count} outPolys={pv.OutsideView.Polygons.Count}"));

View file

@ -19,7 +19,7 @@ namespace AcDream.App.Tests.Rendering;
/// where its geometry was; stair cells failing admission = the pop). /// where its geometry was; stair cells failing admission = the pop).
/// ///
/// Production-matched inputs: Build(root, eye, lookup, viewProj, /// 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. /// mirrors RetailPViewRenderer.DrawInside.
/// ///
/// Scenarios: /// Scenarios:
@ -54,8 +54,7 @@ public class Issue176177FacilityHubFloodReplayTests
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null; Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var frame = PortalVisibilityBuilder.Build( var frame = PortalVisibilityBuilder.Build(
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir), cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
buildingMembership: null, buildingMembership: null);
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var result = new List<uint>(frame.OrderedVisibleCells); var result = new List<uint>(frame.OrderedVisibleCells);
result.Sort(); result.Sort();
return result; return result;

View file

@ -207,7 +207,7 @@ public class Issue177StairDescentCameraFloodTests
string admit = "?"; string admit = "?";
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rc)) 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; var s = f.OrderedVisibleCells;
admit = string.Concat(stair.Select(low => (s.Contains(FacilityHub | low) ? "1" : "0"))); 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); for (int i = 0; i < 140; i++) cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
uint viewer = cam.ViewerCellId; uint viewer = cam.ViewerCellId;
if (viewer == 0u || !renderCells.TryGetValue(viewer, out var rc)) continue; 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++; int c = f.OrderedVisibleCells.Count; sum += c; n++;
if (c > maxCells) { maxCells = c; bestViewer = viewer; bestSet = CellSet(f.OrderedVisibleCells.OrderBy(v => v)); } 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); float d = PortalPlaneDistance(root, upIdx, eye);
var frame = PortalVisibilityBuilder.Build( var frame = PortalVisibilityBuilder.Build(
root, eye, lookup, ViewProj(eye, gaze), root, eye, lookup, ViewProj(eye, gaze),
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); buildingMembership: null);
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList(); var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
bool up = vis.Contains(upperId); bool up = vis.Contains(upperId);
string admit = up ? "ADMITTED" : "DROPPED"; string admit = up ? "ADMITTED" : "DROPPED";
@ -339,8 +339,8 @@ public class Issue177StairDescentCameraFloodTests
// spiral, root 0x01C8, portals off-screen/sliver, camera NOT collision-jammed). // 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 // 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. // 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 fEdge = PortalVisibilityBuilder.Build(root, eyeEdgeOn, lookup, ViewProj(eyeEdgeOn, gaze), null);
var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null, PortalVisibilityBuilder.ShellDrawLiftZ); var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null);
Assert.DoesNotContain(upperId, fEdge.OrderedVisibleCells); // edge-on drops (mechanism) Assert.DoesNotContain(upperId, fEdge.OrderedVisibleCells); // edge-on drops (mechanism)
Assert.Contains(upperId, fOff.OrderedVisibleCells); // off-plane admits (control) Assert.Contains(upperId, fOff.OrderedVisibleCells); // off-plane admits (control)
} }
@ -438,7 +438,7 @@ public class Issue177StairDescentCameraFloodTests
var vp = cam.View * cam.Projection; var vp = cam.View * cam.Projection;
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell)) 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(); 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" : "-")}"; 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( _out.WriteLine(FormattableString.Invariant(
@ -519,7 +519,7 @@ public class Issue177StairDescentCameraFloodTests
{ {
var frame = PortalVisibilityBuilder.Build( var frame = PortalVisibilityBuilder.Build(
rootCell, eye, lookup, viewProj, rootCell, eye, lookup, viewProj,
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); buildingMembership: null);
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList(); var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
floodStr = $"{vis.Count,2} [{CellSet(vis)}]"; floodStr = $"{vis.Count,2} [{CellSet(vis)}]";
bool ramp = vis.Contains(FacilityHub | 0x0182u); bool ramp = vis.Contains(FacilityHub | 0x0182u);

View file

@ -46,8 +46,7 @@ public class Issue181VisFlapReplayTests
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null; Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var frame = PortalVisibilityBuilder.Build( var frame = PortalVisibilityBuilder.Build(
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir), cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
buildingMembership: null, buildingMembership: null);
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var result = new List<uint>(frame.OrderedVisibleCells); var result = new List<uint>(frame.OrderedVisibleCells);
result.Sort(); result.Sort();
return result; return result;
@ -88,8 +87,7 @@ public class Issue181VisFlapReplayTests
{ {
var frame = PortalVisibilityBuilder.Build( var frame = PortalVisibilityBuilder.Build(
cells[LiveRoot], eye, lookup, ViewProjFor(eye, gaze), cells[LiveRoot], eye, lookup, ViewProjFor(eye, gaze),
buildingMembership: null, buildingMembership: null);
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
if (!frame.CellViews.TryGetValue(flapper, out var view) || view.IsEmpty) if (!frame.CellViews.TryGetValue(flapper, out var view) || view.IsEmpty)
{ {