The anchor pass added in f911b5f runs on every element's children — including
UiRoot's children, which are the top-level WINDOWS. With the default Left|Top
anchor, ApplyAnchor reset each window's Left/Top/Width/Height back to its
captured design rect EVERY frame, so user move/resize was undone instantly ("I
can't resize or move it"). A window is user-positioned, so it must not be
anchor-managed by its parent: set UiNineSlicePanel.Anchors = None. Children
INSIDE the window still anchor to it (the bars keep stretching with width).
Regression tests: UiNineSlicePanel.Anchors == None; ApplyAnchor(None) is a no-op.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "red cone" (+ green floor petals) in the 0x0007 Town Network dungeon is a dat
EnvCell static object (Setup 0x02000C39 / GfxObj 0x010028CA) using pure red/green
MARKER textures (0x08000109 / 0x0800010A). It is an EDITOR-ONLY placement marker:
its DIDDegrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX},
i.e. visible ONLY at distance 0 (the WorldBuilder editor origin) and degraded to
GfxObj id 0 (nothing) at any real distance. retail's distance-based degrade
(CPhysicsPart::UpdateViewerDistance 0x0050E030 -> Draw 0x0050D7A0) therefore never
draws it in the live client.
acdream's render pipeline is extracted from WorldBuilder, which (being an editor)
renders every cell static's base mesh directly and has NO degrade handling at all
(zero DIDDegrade references in references/WorldBuilder) — so acdream inherited the
"show the marker" behavior and drew it forever. It only became visible now because
the #135 login-into-dungeon fix drops the player at the exact saved spawn next to it.
Fix: GfxObjDegradeResolver.IsRuntimeHiddenMarker() detects the editor-marker pattern
(HasDIDDegrade + Degrades[0].MaxDist==0 + a degrade entry with Id==0). The EnvCell
static-object hydration (GameWindow ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via
meshRefs.Count==0). This is the faithful equivalent of retail's runtime degrade for
static geometry (always viewed at distance > 0); real LOD objects (slot0.MaxDist>0)
and degrade-to-real-mesh objects are untouched.
Diagnosis was extensive (geometry-not-VFX via particle-off; texture-not-lighting via
flat-ambient frame dumps; per-surface runtime decode pinned the red/green marker
surfaces; a draw-time probe pinned the dat-static entity id; a dat dump of the Setup +
degrade table confirmed the editor-marker pattern). Verified live via a frame dump:
the red cone + green petals are gone, all real dungeon decorations still render.
4 new GfxObjDegradeResolver unit tests cover the marker / normal-LOD / no-table /
degrades-to-real-mesh cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add AnchorEdges [Flags] enum and Anchors property (default Left|Top, so
all existing elements are unchanged) to UiElement. ApplyAnchor() captures
the design-time margins on first call then recomputes Left/Top/Width/Height
each frame; DrawSelfAndChildren drives it for every child before painting.
ComputeAnchoredRect is public + static so it can be unit-tested without a
running frame loop.
MarkupDocument.Build gains a private Anchor() CSV parser and threads it
into the <meter> initializer via the anchor= attribute.
vitals.xml: remove title="Vitals" (retail vitals has no heading) and add
anchor="left,top,right" to all three meter bars so they stretch when the
panel is dragged wider.
Two new xUnit tests in UiRootInputTests: Left+Right stretches width;
Left+Top only keeps fixed size. All 19 App.Tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ResizeX/ResizeY bool properties to UiElement (both true by default).
HitEdges() in UiRoot masks out locked axes after edge detection, so a
locked edge falls through to window-move behaviour — matching retail,
where the vitals bar height is fixed and only widens.
MarkupDocument.Build() parses an optional resize="x|y|both|none"
attribute on <panel>; vitals.xml gets resize="x" to enforce the
horizontal-only constraint in all instances of the panel.
Two new tests: HitEdges_RespectsResizeAxisLock (UiRootInputTests) and
Build_ResizeAttrX_SetsHorizontalOnly (MarkupDocumentTests). 11/11 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TextRenderer.Flush batched by primitive type and flushed rects -> text ->
sprites LAST, so the 8-piece chrome (incl. the center fill) painted OVER the
vital bars + numbers ("the window is drawn in front of the bars"). Reorder to
sprites -> rects -> text so chrome composites behind widget fills + text.
Correct while bars are solid rects; when bars become gradient SPRITES this must
move to true submission/painter order (sprite-on-sprite z) — noted inline as the
D.2b follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add parallel resize mode to the UiRoot retained-mode input state machine.
A left-drag starting within ResizeGrip=5px of a Resizable window's edge or
corner resizes it (min-size clamped); interior drags on a Draggable window
still reposition it.
Changes:
- UiElement: Resizable, MinWidth, MinHeight properties
- UiRoot: ResizeEdges flags enum; _resizeTarget state fields; FindWindow
(replaces FindDraggable, matches Draggable||Resizable); HitEdges (static,
internal, testable); ResizeRect (static, public, testable); OnMouseDown
checks edge-grip before move; OnMouseMove resize branch precedes move;
OnMouseUp clears _resizeTarget
- UiNineSlicePanel: Resizable = true (retail windows are resizable)
- UiRootInputTests: 4 new tests — ResizeRect_RightBottom, ResizeRect_LeftTop
(min-clamp + origin shift), HitEdges_DetectsCornerAndInteriorNone,
EdgeDrag_ResizesPanel_InteriorDragMoves (full integration path)
Note on test coordinate: right-edge grab uses x=298 (2px inside the panel's
hit-test boundary) rather than x=300 (exactly at edge, misses OnHitTest's
strict `<` check). This is intentional — the grip zone extends inward from
the edge boundary, so a click 2px inside correctly lands in both the
hit-test rect AND the resize-grip zone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- UiElement: add Draggable flag; left-drag on a draggable element repositions
it as a floating window instead of starting a drag-drop sequence.
- UiRoot: add WantsMouse/WantsKeyboard properties (mirrors ImGui's WantCaptureMouse
pattern); add FindDraggable helper; inject _windowDragTarget state machine into
OnMouseDown/OnMouseMove/OnMouseUp so draggable windows track the pointer offset.
- UiNineSlicePanel: set Draggable=true so retail window frames are movable by default.
- GameWindow: OR _uiHost?.Root.WantsMouse|WantsKeyboard into the SilkMouseSource
wantCaptureMouse/wantCaptureKeyboard delegates and the direct MouseMove gate so
game actions (movement, world-pick) are suppressed while the pointer is over a
retail window — no double-handling with the InputDispatcher.
- GameWindow: wire all Silk Mice/Keyboards to UiHost after construction so the
UiRoot tree receives live input.
- Tests: 3 new UiRootInputTests covering WantsMouse hit-test, window-drag
reposition, and non-draggable panel immobility.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the plugin-facing UI registration surface (Task 9, final D.2b task).
Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are
buffered in BufferedUiRegistry before the GL window opens, then drained into
UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first-
party vitals panel. Faulty plugin markup is isolated (try/catch per panel,
logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in
Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements Task 8 of the D.2b retail-UI plan. MarkupDocument.Build() parses
KSML-style panel markup into a live UiNineSlicePanel subtree, resolving
{Binding} attribute expressions against a supplied object via reflection.
Color format is #AARRGGBB (alpha-first, matching controls.ini). Handles
<panel> root (geometry + optional title label) and <meter> children (fill,
label, bar color). Future element kinds (label, button, image) extend the
switch without touching existing code.
vitals.xml encodes the just-approved vitals panel layout (health red #FFC70D0D,
stamina gold #FFD49E1F, mana blue #FF1F33D9); ships next to the binary via
PreserveNewest csproj rule. GameWindow.cs drops the 35-line hand-built panel
block in favour of a 4-line File.ReadAllText + MarkupDocument.Build call —
identical tree, identical render, now data-driven.
2 new tests (Build_CreatesPanelWithMeterFillLabelAndGeometry,
Build_NullBindingValuesYieldNullFillAndLabel) + 11 total targeted green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds ControlsIni — a minimal flat-INI reader for retail's controls.ini
(#AARRGGBB alpha-first color tokens; case-insensitive section/key lookup;
missing file returns an empty sheet with no throw). Wires the [title]
color token into the vitals panel's UiLabel in GameWindow.OnLoad, with
hardcoded white as the fallback. Visually a no-op (retail's [title] color
is white), but proves the stylesheet plumbing end-to-end (D.2b §7).
Three unit tests cover section parsing, #AARRGGBB decode, and graceful
missing-file handling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two regressions from the pre-collapse (712f17f), found by live gate + a runtime
probe:
1) Login-into-dungeon stopped loading the dungeon. The login-hold streaming
observer fell through to the OFFLINE fly-camera branch once
_lastLivePlayerLandblockId was filtered to the player guid (a dungeon-local
NPC used to keep it pinned). A camera-derived observer far from the
pre-collapsed dungeon tripped ExitDungeonExpand and unloaded it. Fix: a LIVE
in-world session never uses the fly camera for the observer — it follows the
player's server landblock, falling back to the recentered spawn center
(_liveCenterX/Y). The fly camera is the OFFLINE observer only.
2) Even with the dungeon resident, auto-entry hung: the #106 "ground ready" gate
required SampleTerrainZ under the spawn, but a dungeon's negative-offset cells
place the spawn's WORLD position in a NEIGHBOUR terrain landblock the #135
collapse deliberately doesn't load (probe: cellReady=True, terrReady=False
forever). The terrain gate is wrong for an indoor spawn — the player lands on
the EnvCell FLOOR. Fix: gate an indoor (hydratable) spawn/teleport on
IsSpawnCellReady, not the terrain heightmap; outdoor (and unhydratable→demote)
spawns still hold on terrain. Applied to both isSpawnGroundReady (login auto-
entry) and TeleportArrivalReadiness (teleport). This is the faithful equivalent
of retail's synchronous cell load + place-on-floor; the pre-#135 terrain hold
only passed because the 25x25 window streamed the neighbour terrain.
Verified live: login into 0x0007 → auto-entered player mode, snapped to
0x00070145, dungeon renders, FPS steady. Register AD-2 amended.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the dormant AcDream.App/UI retained-mode tree into GameWindow under
ACDREAM_RETAIL_UI=1: an 8-piece dat-sprite UiNineSlicePanel framing three
UiMeter vital bars bound to the existing VitalsVM. Render-only (UiHost input not
yet bridged to the InputDispatcher — next sub-phase). Coexists with the ImGui
devtools path; no regression there.
Visually verified against a live retail client: the bars match retail's vitals
structure (three stacked horizontal bars, current/max numbers centered) — so the
earlier "orbs" assumption was wrong (retail vitals ARE bars), and stamina is GOLD
not cyan (the #10F0F0 research note was wrong). UiMeter gains a centered numeric
Label (stub debug font for now). Spec §8 + the markup example corrected to match.
Bookkeeping: retired divergence row TS-30 (flat-rect panels -> real dat chrome)
and added IA-15 (our UiHost/markup engine vs keystone.dll's LayoutDesc tree).
Remaining polish (filed, §15): glassy gradient bar fill sprite + the retail dat
font for the numbers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s.
Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to
the player's single dungeon landblock — AC dungeons have no neighbours) only
fires once the per-frame `insideDungeon` gate reads true, and that gate keys on
the physics CurrCell, which isn't set until the player is PLACED, which waits for
the dungeon landblock to hydrate. So during the whole hydration window NormalTick
bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their
~19k entities each — and the collapse only mops them up afterward. That mop-up is
the ramp.
Fix: trigger the SAME collapse early, the instant we recenter the streaming center
onto a sealed dungeon cell, before the first NormalTick.
- StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse
early (idempotent). The expensive neighbour window is never enqueued.
- GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag
(CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the
per-frame gate use, so the early decision matches the eventual one. Distinguishes
a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor
surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id
can't type-confuse a LandBlock record as an EnvCell.
- Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport).
- Observer robustness: during a teleport PortalSpace hold the streaming observer
follows the recentered destination, not the frozen pre-teleport position (which
could drift >=2 landblocks off and trip ExitDungeonExpand). And
_lastLivePlayerLandblockId is now filtered to the player guid (resolves the
Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer
off the dungeon.
Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new
workaround — AP-36 amended in the same commit. Adversarially reviewed across
timing / threading / faithfulness lenses; 5 new tests including the real runtime
ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds UiMeter, the horizontal vital-bar widget for the D.2b retail-look
UI toolkit. Solid-color fill for Spec 1; the retail orb sprite + scissor
crop path is reserved for a later sub-phase. Five unit tests (1 Fact +
4 Theory) cover half-fill geometry and clamping at -1/0/1/2 fractions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the retail floating-window bevel as a UiPanel subclass using
RetailChromeSprites: 4 tiled edges + 4 stretched corners + tiled center fill,
matching the 8-piece border layout confirmed by the D.2b Step-0 prove-out.
Resolver delegate keeps GL out of unit tests. Geometry verified by
ComputeFrameRects_PlacesCornersEdgesAndCenter (1/1 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step-0 prove-out result: retail UI chrome sprites are RenderSurface objects
(0x06xxxxxx) that must be decoded DIRECTLY, not via the Surface->SurfaceTexture
chain GetOrUpload uses for world materials (which produced 1x1 magenta/garbage).
Added TextureCache.GetOrUploadRenderSurface(id, out w, out h) — Portal/HighRes
TryGet<RenderSurface> -> DecodeRenderSurface(palette:null) -> upload, separately
cached. This is the path UI chrome + (later) dat fonts use.
Confirmed the universal floating-window bevel is an 8-piece border + center fill:
center 0x06004CC2 (48x48)
edges 0x060074BF/C1 (10x5 horiz) 0x060074C0/C2 (5x10 vert)
corners 0x060074C3..C6 (5x5)
Recorded in RetailChromeSprites.cs (edge/corner->position mapping is a best
guess pending the LayoutDesc 0x21000040 parse; visually confirmed at panel
render). The memory-note ids were right; only the decode path was wrong.
Temporary prove-out harness (added to GameWindow.OnRender) removed. proveout*.log
gitignored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add uUseTexture==2 (RGBA modulate) branch to ui_text.frag so dat sprites
can be drawn through the existing 2D batcher without touching the font path.
TextRenderer gains _spriteBufs (per-GL-handle List<float>), DrawSprite(), and
a Flush block that issues one draw call per distinct texture with uUseTexture=2.
Also adds DepthMask(false) in the state-save block (restored to true after) to
prevent the transparent-quad pass from writing depth and corrupting the 3D scene
if the UI is flushed mid-frame.
TextureCache gains GetOrUpload(surfaceId, out width, out height) — caches pixel
dimensions alongside the GL handle so UI 9-slice geometry can compute slice UVs
from the source image size without a second decode.
UiRenderContext gains a DrawSprite forwarder that applies the current 2D
translate stack, matching the DrawRect / DrawRectOutline pattern.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the throwaway probes added to diagnose the dungeon FPS/grey issues now that
they're fixed: the ACDREAM_LOG_FPS headless line + [cellreg] registration line
(GameWindow), and the [pv-trace] 0x0007 gate-widen + raw-NDC bbox addition to the
flap probe (PortalVisibilityBuilder, reverted to the pre-#133 form). The permanent
Phase-U.4c [flap]/[pv-trace] probes (ACDREAM_PROBE_FLAP) are kept as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds two startup-time env toggles that Phase D.2b's retail-UI panel
frame will read:
- ACDREAM_RETAIL_UI=1 → opts.RetailUi (bool, default false)
- ACDREAM_AC_DIR=<path> → opts.AcDir (string?, default null)
Both follow the existing helper conventions (IsExactlyOne / NullIfEmpty).
No call sites broke because the only construction site in RuntimeOptions.cs
already uses named arguments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After registering portals-only connector cells for VISIBILITY (d90c538), an
angle-dependent residual grey remained when the camera crossed a ramp: the
camera-collision sweep (SmartBox::update_viewer -> sphere_path.curr_cell, pc:92870)
could not transit INTO the connector cell because it had no physics cell to sweep
into — CacheCellStruct was still gated on drawable sub-meshes. So the viewer cell
stalled one cell behind the eye (confirmed live: [flap-sweep] transited every cached
neighbour but NEVER the un-cached connector 0x014D, viewerCell stuck at 0x00070103
while the eye sat 1.32 m past the connector's portal plane), and the side test
correctly culled the on-screen connector portal -> grey.
Fix: move CacheCellStruct out of the `cellSubMeshes.Count > 0` gate, next to
BuildLoadedCell — cache EVERY cell with a valid cellStruct for physics too. Retail
keeps the whole landblock cell array resident for the sweep; a portals-only
connector has an empty collision BSP but its portals drive the transit. User-gated:
"I see no grey background any longer."
Build green; 12 flood-gate tests + 677 physics/cell/transit tests green (no collision
or membership regression). TEMP render probes still retained (strip after).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The grey "barrier" at a dungeon ramp was a one-cell registration gap. The ramp's
connector cell (0x0007014D) is a portals-only pass-through — CellMesh.Build yields
0 drawable sub-meshes for it (you walk through it on adjacent floors). But the whole
registration block — including the portal-VISIBILITY registration (BuildLoadedCell ->
_cellVisibility) — was gated behind `if (cellSubMeshes.Count > 0)`. So that cell was
never added to the visibility graph; the flood lookup-missed it (PortalVisibilityBuilder
:369), couldn't traverse it to the room below, and the grey clear color showed through.
Confirmed live via two added probes: [cellreg] registered=204/205 (only 0x014D missing)
+ [pv-trace] p4->0x0007014D skip=lookup-miss. After the fix: registered=205,
hasRamp=True, skip=lookup-miss gone, the room below renders.
Fix: compute the cell transforms and call BuildLoadedCell (visibility) for EVERY cell
with a valid cellStruct, regardless of drawable sub-meshes — matching retail, which
keeps the whole landblock cell array resident before the flood runs. Drawing
(RegisterCell, _pendingCellMeshes) and the physics BSP (CacheCellStruct) stay gated on
drawable geometry (a portals-only connector has nothing to draw and no collision
surface). Not a regression from the FPS-collapse work — a pre-existing gate the
now-navigable dungeon exposed (every ramp/stair/cellar mouth would show it).
TEMP diagnostics retained for the residual angle-grey investigation (strip after):
[cellreg] (GameWindow), the 0x0007 [pv-trace] gate widen + raw-NDC bbox (PortalVisibility-
Builder). Three earlier render-math theories (portal_side, on-screen clip, near-eye
projection) were each refuted by apparatus/probe before shipping — this is the verified one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dungeon-streaming gate read SeenOutside from the render registry
(_cellVisibility.TryGetCell), which only succeeds AFTER the landblock FINALIZES —
~tens of seconds for a 205-cell dungeon. So the collapse fired late and the full
25x25 neighbor window churned in first ("~30s to stabilize at high FPS").
EnvCell extends ObjCell, which already carries SeenOutside (set from the EnvCell
dat flags at construction), so CurrCell.SeenOutside is available the moment the
player is placed (the snap). Read it directly instead of the registry. Collapse now
engages ~3s in (snap) instead of ~30s (finalize); residual is the ~24 neighbors the
bootstrap loads before the snap, which then unload. Also simplifies the predicate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the dungeon-collapse fix the local player avatar stopped rendering: the
per-frame RelocateEntity moved the player entity to its position-derived landblock
floor(pp/192), which for a dungeon's negative-local-Y cell is the off-by-one (0,6)
— the very landblock the collapse unloads. So the player entity sat in an unloaded
landblock and was never drawn (the dungeon itself, in 0x0007, rendered fine).
Fix: when the player is in an indoor cell (CellId low word >= 0x0100), relocate to
the cell's OWN landblock (CellId >> 16), matching the streaming-collapse pin. The
cell id is authoritative for ocean-placed dungeon geometry. Outdoor entities keep
the position-derived path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"The dungeon is broken" — the collapse was unloading the REAL dungeon. A dungeon's
EnvCells sit at arbitrary "ocean" world coords with negative cell-local Y (snap
showed pos=(58.9,-69.6) in cell 0x00070133), so the observer landblock
_liveCenterY + floor(pp.Y/192) = 7 + floor(-69.6/192) = 7 + (-1) = 6 lands one row
off. The collapse pinned to 0x0006 and unloaded 0x0007 — the real dungeon — which
nulled CurrCell (the cell no longer existed) and left the player floating in
outdoor-lit empty space (lb 1/1 @ ~1585 fps, but the wrong landblock). This is the
Bug-A negative-local-coordinate class.
Fix: when inside a dungeon, pin the collapse to the cell's OWN landblock
(CurrCell.Id >> 16), never the position-derived observer landblock — the cell id is
the authoritative landblock for ocean-placed dungeon geometry.
Also hardened the hysteresis so a transient CurrCell flicker can't thrash:
- Re-collapse when insideDungeon at a DIFFERENT landblock (multi-landblock dungeon).
- Expand only on a DISTANT move (Chebyshev > 1) — a real exit teleports far from the
ocean-grid block; the off-by-one flicker is always an ADJACENT (±1) landblock, so
it now HOLDS the collapse instead of expanding.
- SweepCollapsed always preserves _collapsedCenter (the true dungeon landblock),
never the per-frame observer landblock.
Build green; 59 streaming tests green (flicker regression test updated to the
realistic adjacent off-by-one).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first cut of the dungeon gate keyed expand on the per-frame insideDungeon
signal (CurrCell is a sealed EnvCell). Live, CurrCell momentarily resolves to
null mid-frame while the player stays put in the dungeon landblock, so the gate
flipped collapse→expand→collapse every few frames. Each expand re-streamed the
full 25×25 window; the unloads couldn't keep up (MaxCompletionsPerFrame=4), so
registered lights leaked to 212k and FPS spiked to single digits between the
~199 fps collapsed frames.
Fix: once collapsed, key the gate on the STABLE observer landblock, not CurrCell.
Stay collapsed while the player remains in the dungeon landblock (_collapsedCenter);
expand only when the observer actually moves to a different landblock (portal/
teleport out). CurrCell flicker no longer thrashes.
Regression test added (Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand).
Build green; 60 streaming tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dungeon FPS sat at ~30 (frame ~33ms) because the 25x25 streaming window around
the dungeon landblock pulled in ~129 NEIGHBORING landblocks + their thousands of
torch/particle emitters, all drawn though never visible. In AC all dungeons are
packed adjacent in the unused "ocean" map grid, so those neighbors are unrelated
dungeons. The FPS timeline proved it: 247 fps at login (lb 0/0, ~10K entities) →
17 → 30 as landblocks streamed in (lb 0→129) — the cost tracked LANDBLOCK count,
not entities.
Retail-faithful: ACE LandblockManager.GetAdjacentIDs returns ZERO adjacents for a
dungeon (`if (landblock.IsDungeon) return adjacents;`, Landblock.cs:577-582) —
every dungeon is a self-contained landblock you never see out of.
Fix: when the player stands in a sealed indoor cell (CurrCell.IsEnv &&
!SeenOutside — the same predicate that kills the sun/sky), collapse streaming to
just the player's dungeon landblock and unload the neighbors. Building interiors
(cottage/inn) have SeenOutside cells, so they are NOT gated and keep their
surrounding terrain (the frozen building/cellar demo is unaffected). Unloading the
neighbors also tears down their lights (removeTerrain → UnregisterOwner), shrinking
LightManager._all from ~2227 toward retail's ≤40 — which directly helps the A7
lighting bake landing next.
Mechanics (StreamingController):
- Edge IN: ClearPendingLoads() cancels the in-flight 25x25 window (new streamer
ClearLoads control job — worker drops queued Loads, keeps Unloads), unload every
resident neighbor, pin a radius-0 StreamingRegion, (re)load the dungeon block if
needed.
- Stay collapsed: sweep any straggler that finished loading after the edge (a Load
the worker had already dequeued before ClearLoads).
- Edge OUT (portal/teleport to outdoors): rebuild the full two-tier window at the
new center, unload anything stale.
AP-36 added to the divergence register (the gate uses the cheap SeenOutside cell
predicate as an approximation of ACE's full landblock IsDungeon classification).
GameWindow also carries a TEMP ACDREAM_LOG_FPS=1 headless FPS line (strip after
the A7 FPS+lighting verification).
Build green; 58 streaming tests green (6 new dungeon-gate tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights")
because our point/spot shader used `atten = 1.0` flat inside a hard `d < range`
cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation
inside Range... the bubble-of-light look relies on crisp boundaries", citing
r13 10.2) — that was a misread and the literal cause of the symptom.
Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the
PER-VERTEX point-light path that lights static walls) scales each light's
contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0
at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor,
and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light
rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different
path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the
oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L)
are x87-obscured (same artifact class as GetPowerBarLevel) and left unported.
Changes:
- mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1);
Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the
false "no attenuation / crisp bubble" comment in mesh.frag.
- LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5.
- LightManager: correct the stale class doc comment (Tick is now nearest-8
allocation-free partial-select with NO viewer-range slack filter).
- divergence register: AP-16 updated (slack filter removed), AP-35 added
(per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization).
- test: LightingHookSinkTests Range 8*1.3 = 10.4.
Build + 20 lighting tests green. Visual gate pending (game-wide lighting change:
dungeon torches, house candles, outdoor braziers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A character saved inside a far dungeon hung at the #107 auto-entry hold because
the streaming center was fixed at the startup default and the login spawn never
recentered it, so the dungeon never streamed. Mirror the teleport-arrival
recenter on the login player-spawn path: when the player's spawn landblock
differs from the current center, recenter before translating the spawn position
(landblock-local -> new-center frame). No-op for a same-landblock (normal
Holtburg) login.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review follow-up: the hydration decouple's safety rests not only on
CacheCellStruct self-gating its BSP cache, but on the fact that a geometry-less
cell — though now added to the UCG CellGraph unconditionally — never enters the
_cellStruct BSP dictionary membership/placement resolve through, so the player
can never be rooted in one. Document that load-bearing invariant at the hoist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a
geometry-less collision cell got no collision (fall-through) and no visibility
node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a
null PhysicsBSP, so this is safe. Render registration stays behind the submesh
guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the unconditional OnLivePositionUpdated snap (which resolved against
the resident old landblocks before the destination streamed in -> ocean) with a
recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111
validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or
force-snaps loudly on an impossible claim / ~10s timeout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GL root cause was fixed in fcade06 (the gpu_us query-ring stale
errors). This closes the remaining design debt: a genuinely-failed
UploadMeshData was dropped permanently.
Exact mechanism (traced this session): UploadMeshData's catch returns
null, the staged item is already consumed, and _renderData stays empty -
but the prepared data lingers in _cpuMeshCache, so the #128 EnsureLoaded
re-arm hits PrepareMeshDataAsync's CPU-cache short-circuit
(ObjectMeshManager.cs:448-453) which returns the cached data WITHOUT
re-staging it for upload. The mesh stays invisible until CPU-cache
eviction - session-sticky under low cache pressure (the in-tower
scenario).
Fix: the per-frame Tick drain (WbMeshAdapter) now re-stages a failed
upload for the NEXT frame via ObjectMeshManager.UploadOrRequeue, bounded
by MaxUploadRetries (3). The attempt counter lives on the ObjectMeshData
object so it resets to 0 naturally on re-prepare. Re-stages are
collected and re-enqueued AFTER the drain loop, never inside it, so a
deterministic failure cannot spin the queue within a single frame; past
the cap it gives up with a loud [up-retry] ... giving up line - a
genuine GL defect now surfaces instead of the old silent permanent drop
or an unbounded retry storm. Retail loads content synchronously and has
no such failure mode; this converges the async pipeline toward that
guarantee.
The uncaught GenerateMipmaps path (open-question c) is INTENTIONALLY
left to surface errors - a blanket catch there would mask future real
defects (no-workarounds rule), and its trigger (fcade06) is retired.
No visual gate (robustness). Build green; App.Tests 264 + WbMeshAdapter
tests green. No GL-context test seam exists for the upload path, so the
bounded retry is verified by construction + the regression suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cellar-ascent grass window was the UNDERSIDE of the z~94 grade
sheet. Retail terrain is single-sided: ACRender::landPolysDraw
(0x006b7040) draws each land triangle ONLY when the camera is on the
POSITIVE (upper) side of its plane (Plane::which_side2 vs
Render::FrameCurrent, zFightTerrainAdjust bias) - a below-grade eye
gets NO terrain, so retail shows sky through the cellar door.
We inherited WB's frame-global cull DISABLE (WB GameScene.cs:841 - an
editor camera goes underground by design) and TerrainModernRenderer.Draw
set no cull state of its own -> terrain rasterized both sides. From a
below-grade eye every aperture sight-ray RISES, so the only 'terrain'
it can see is the grade sheet's underside - which painted the exit-door
aperture (the landscape slice's 2D NDC clip planes (nx,ny,0,dw) have no
depth axis and cannot exclude between-eye-and-portal geometry) and slid
off the door exactly as the eye crossed grade. Membership/viewer was
exonerated by the harness in the previous commit.
Fix: TerrainModernRenderer.Draw owns its cull state (the 7th
self-contained-GL-state instance): Enable(CullFace) + CullFace(Back) +
FrontFace(Ccw), set -> draw -> restore the frame-global CW + cull-off
baseline. GL backface culling evaluates retail's per-triangle eye-side
predicate at rasterization; no shader change.
Pins:
- LandblockMeshTests.Build_AllTriangles_WindCounterClockwiseInWorldXY:
every emitted triangle CCW in world XY across both FSplitNESW split
directions - the winding invariant culling depends on.
- TerrainCullOrientationTests: under the production camera convention
(LookAt up=+Z, Numerics perspective) an up-facing triangle winds CCW
in window space from above (kept) and CW from below (culled) - guards
FrontFace inversion, which would blank terrain from above.
Oracle note: retail's through-portal clip has NO portal-face near plane
(PView::GetClip / Render::set_view install edge planes only); nearer-
than-portal exclusion comes from the eye-side cull + cell-level
admission. No register row: this PORTS the retail mechanism, retiring
an undocumented WB-heritage deviation.
Gate pending: cellar climb (grass window gone) + outdoor sanity glance
(terrain intact from above).
Suites: App 263+1skip / Core 1443+2skip / UI 420 / Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The headless replay of the captured indoor frame proved the look-in flood ADMITS the porch 0x017A (Diagnostic_LookInFlood_AdmitsHallPorchFromCottage: 14 cells). So the portal (a SERVER object - the teleport proves it - with ParentCellId 0xA9B4017A) routes to partition.Dynamics and draws NOWHERE under an interior root: dynamics-last viewcone-culls it (the main cone has no look-in cells) and post-seal it would z-fail beyond the root's door plane (the #118 lesson). This is AP-33's own recorded deferral - 'look-in DYNAMICS are not drawn' - the deferred case was the most-stared-at object in town. Outdoors the merge path puts the porch in the main cone -> drawn -> 'appears when I walk out'.
Fix: DrawBuildingLookIns pass 2 draws look-in-cell dynamics with the statics (whole, AP-33 over-include) and their emitters ride the same DrawCellParticles call. No double-draw: dynamics-last keeps culling them; DrawDynamicsParticles only sees its cone survivors. #124 CLOSED by user gate same session. AP-33 row updated. Suites: App 261+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The teleport capture pinned it: walking into the portal flipped pCell to 0xA9B4017A - the hall's PORCH EnvCell. The swirl emitter is owned by a static inside another building's cell. Outdoors the merge path runs the main per-cell pass incl. DrawCellParticles -> visible; under an interior root the #124 look-in sub-pass drew shells + statics but had no cell-particles call. Retail's nested DrawCells draws objects WITH their emitters (DrawObjCellForDummies pc:432878+). Fix: DrawBuildingLookIns pass 2 invokes DrawCellParticles per look-in cell with its static bucket. The owner-cone verdicts were geometrically correct all along (0xC0A9B462 = a porch torch); fixes 1-2 were real-but-adjacent (the unattached pass plugs an independent hole; the alpha deferral fixed#132).
Suites: App 260+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the swirl gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
unattached=0 in the last capture refuted the unattached hypothesis (the fix-1 pass is vacuous); the swirl outdoors rides a MATCHED attached emitter, so its owner is an OutdoorStatic keyed by a synthetic id. The matched-ids dump on an inside-vs-outside capture pair names the owner: the id that flips. Issue131SetupProbeTests dumps the outstage candidate setups from the dat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User gate on 20d1730: the candle is FIXED indoors ("now the candle
light is visible when I'm in the house when it is in front of the
opening") and the OUTDOOR sibling surfaced exactly as AP-34 recorded
("when I go out it is not showing unless I turn so the angle doesn't
put it in front of the opening"): under an OUTDOOR root the merged
building interiors draw AFTER the landscape stage (DrawEnvCellShells),
so a slice-drawn flame is overpainted by a punched aperture's interior
behind it.
Fix: outdoor roots SKIP the late-slice Scene-particle draw; attached
outdoor-static scene emitters draw in the POST-FRAME pass alongside the
T3 unattached pass, where depth is complete and flames composite
correctly against interiors. The owner-id set carries over from the
late slice (single full-screen slice outdoors); cell-pass and
dynamics-pass emitters keep their own passes (their owners are never in
the outdoor-static id set - no double-draw). Interior roots keep the
late-slice draw (their stage ends with the clear + seal discipline).
AP-34 row updated (the outdoor residual is now covered; the remaining
residual is translucent MESH batches within stage draw calls).
Portal swirl (#131): the user's "same results" on 20d1730 KILLS the
look-in-erasure hypothesis for the portal - the mesh now draws after
the look-ins and is still missing indoors. No further speculative fix;
the [outstage] probe now prints each outside-stage dynamic's
SourceGfxObjOrSetupId (portals have distinctive setups) and
[outstage-pt] lists up to 12 distinct UNMATCHED attached emitter owner
ids - the next capture identifies whether the portal entity reaches the
through-door draw at all, and where its emitters point.
Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's screenshot pair re-attributed both reports to ONE mechanism -
a compositing gap in the #124 look-in sub-pass:
- #131: the portal swirl (a TRANSLUCENT MESH, not only particles) stood
exactly in front of the hall's doorway. The slice drew it BEFORE the
look-in sub-pass; translucents write no depth, so the hall's interior
- drawn into its far-Z-punched aperture - overpainted the swirl.
Outdoors the look-ins are the post-stage merge path, so the swirl
survives ("stepping out it pops into existence").
- #132: the candle/lantern flame is an attached emitter in the slice's
Scene-particle pass - same pre-look-in placement, same erasure
whenever "the opening through a house" sat behind it; against a wall
nothing overdraws it. Background-dependence explained exactly.
Retail cannot exhibit this class: every alpha draw of the landscape
stage is collected and flushed ONCE after LScape::draw
(D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722) - i.e.
after all building look-ins.
Port (the two-phase split): DrawLandscapeThroughOutsideView now runs
EARLY per slice (sky, terrain, outdoor STATIC meshes - the look-in
punches need their depth to mark against, the #117 lesson), then the
#124 look-ins, then LATE per slice (outside-stage dynamics' meshes +
ALL attached scene particles + weather + SkyPostScene), then the #131
unattached pass. New RetailPViewLandscapeLateSliceContext carries the
dynamics survivors + the particle-owner set (statics + dynamics cone
survivors). GameWindow's slice handler split accordingly. Outdoor
roots: no look-ins live in the stage, so the net order is unchanged
(zero behavior change outdoors).
Register: AP-34 added - the two-phase split vs retail's single
deferred flush, with the residuals recorded (outdoor-root slice
particles still draw before merged building interiors - the unreported
outdoor sibling; building exteriors' own translucent batches draw
early).
The earlier #131 unattached-emitter pass (1d3f9a8) remains - it fixes
an independent hole (that class had NO indoor pass at all) - and now
runs at the end of the late phase.
Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user gate: swirl through the doorway, candle flame with
the opening behind it, far-building interiors (#124).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's capture run + a code read pinned it in one step: every
particle pass under an interior root is id-filtered (the landscape
slice's Scene pass, the per-cell pass, and the dynamics pass all
require AttachedObjectId != 0 plus owner-set membership). An UNATTACHED
emitter - AttachedObjectId == 0: portal swirls, campfires, ground
effects anchored at a position - drew NOWHERE when the viewer root was
interior. The outdoor root has the dedicated T3 pass for exactly this
class (its own comment records that "unattached ones had NO pass on
outdoor-node frames"); the identical hole on interior-root frames was
never plugged. Walking out flips to the outdoor root and the T3 pass
picks the swirl up - "appears when I walk out again", verbatim.
The [outstage] capture corroborated the rest of the chain healthy
under the interior root: outside-stage routing correct, cone PASS for
the portal-family dynamics, 57 attached emitters matched and drawn
through the doorway. Only the unattached class was orphaned.
Fix: RetailPViewDrawContext.DrawUnattachedSceneParticles - invoked ONCE
per interior-root frame at the END of the landscape stage:
- pre-clear, because drawn after the depth clear + seals an outdoor
emitter beyond the door plane z-fails against the seal's door-plane
stamp;
- after the #124 look-in sub-pass, so swirls blend over far-building
interiors;
- once per frame, not per slice - alpha particles must not double-draw
(the #121 lesson);
- mutually exclusive with the outdoor T3 pass by root kind (interior
invokes this; outdoor keeps T3).
Residual (documented in the issue): unattached INDOOR emitters now draw
pre-clear and get overpainted by the room's shells - the same
invisibility they had before this fix; the proper per-emitter cell
classification is a future port.
[outstage-pt] probe extended with the unattached emitter count (the
probe's blind spot was exactly where the bug hid).
Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user gate: the swirl through the doorway. #132 (candle
flame vs through-opening background) remains open - different
mechanism, background-dependent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two user reports from the #124 gate session, both axioms:
- #131: "the portal swirl is missing, when I look out from inside a
house. Appears when I walk out again." Mechanism frame: under an
interior root an outdoor dynamic's particles draw ONLY via the
landscape slice's Scene pass (#118 outside-stage routing; #121
excludes them from the last-pass particle callback) - if any link
fails, the swirl draws nowhere exactly when indoors. Desk-exonerated
already: filter key conventions uniform, the routing predicate
correct, sphere from vertex bounds.
- #132: "I have a candle ... when a wall is behind it it shows, but if
I turn a bit and the opening through a house is behind it candle
light disappears." Background-dependent => per-pixel depth/blend at
the aperture region, not owner culling. Possible overlap with the
#124 look-in sub-pass (new pre-clear content in those pixels) - the
pre-77cef4c check is in the issue.
Apparatus (env-gated, zero cost off): ACDREAM_PROBE_OUTSTAGE=1 ->
[outstage] per-slice outside-stage routing + cone verdict per dynamic
(print-on-change, RetailPViewRenderer) + [outstage-pt] slice
Scene-particle id set + live attached-emitter match count (GameWindow).
One capture standing inside looking at the portal pins which link
breaks.
Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From inside a building, looking out at ANOTHER building with an opening
showed its back walls missing (see-through to the world): per-building
look-in floods only ran for outdoor roots; under an interior root the
far building's interior never flooded.
Decomp anchor (named-retail, this session's read): retail runs the
look-in INSIDE the landscape stage for ANY root - LScape::draw is the
FIRST call of PView::DrawCells' outside-view branch (pc:432719),
strictly BEFORE the depth clear (pc:432732) and the exit-portal seals
(pc:432785). ConstructView(CBldPortal) (0x005a59a0) clips each aperture
via GetClip against the INSTALLED view - the accumulated doorway region
when looked into from inside - and build_draw_portals_only pass 1
far-Z punches ALL apertures before pass 2 floods + draws any interior
cell. The nested DrawCells has an empty outside view (PView ctor
draw_landscape=0): no recursive landscape/clear/seal.
Port:
- GameWindow's per-building gather (frustum pre-gate on
Building.PortalBounds) now runs for interior roots too; the root's
own doorway self-excludes via the seed eye-side test (the eye is on
its interior side).
- PortalVisibilityBuilder.BuildFromExterior/ConstructViewBuilding gain
seedRegion - the installed-view clip: interior-root look-ins seed
against the OutsideView polygons (a building not visible through the
doorway never floods); null = full screen (outdoor roots unchanged).
- RetailPViewRenderer.DrawBuildingLookIns: a landscape-stage sub-pass
(before ClearDepthForInterior + seals) - per building, punch ALL
apertures (new DrawLookInPortalPunch callback, always forceFarZ=true,
closing the ISSUES "forceFarZ keys on root kind, under-punches" gap),
then draw the flooded cells' shells + statics far->near. Look-in
frames are NEVER merged into the main frame: a merged cell would draw
post-clear and z-fail against the root's seal (the old ledger
portShape sketch was wrong on this point).
- Look-in cells join the Prepare + partition set so shells have batches
and statics route to ByCell (consumed only by the sub-pass; the main
cell-object pass iterates the main flood's cells).
Register: AP-33 added in the same commit - look-in statics draw WHOLE
(no per-part viewcone; over-include is the safe direction) and look-in
DYNAMICS are deferred (an NPC inside a far building stays invisible -
retail draws objects per overlapped cell in the landscape stage).
Pins: Issue124LookInSeedRegionTests on the real corner-building door -
a seed region containing the aperture floods (and never more than the
full-screen seed), a disjoint region floods NOTHING, and an
interior-side eye never seeds its own exit portal.
Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user gate: far-building interiors visible through their
apertures from inside; #130 re-gate (top-edge strip) rides the same
launch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's re-gate refuted the scissor fix as THE strip (6c4b6d6 was a
real but sub-pixel under-coverage): the strip survived, screenshot at a
doorway, full width of the opening, top edge only, "very subtle".
Root cause (pinned by Issue130DoorwayStripTests.UnliftedGate_*): the
+0.02 m shell render lift. Cell shells DRAW 2 cm above the dat origin
(z-fight vs coplanar terrain); f35cb8b (the #119-residual fix,
2026-06-11) deliberately reverted the VISIBILITY graph to the physics
(unlifted) transform - but the OutsideView color gate (terrain/sky/
scissor through the doorway) and the seal/punch depth fans are
DRAW-space consumers and kept projecting the unlifted polygons. The
drawn lintel therefore sits one lift-projection above the gate's top
edge - measured 6.7 px at a 2.4 m doorway - and that band never
receives terrain/sky color while the seal also stamps 2 cm low.
A regression from f35cb8b, NOT from the W=0 clip port (987313a stays
exonerated). Vertical aperture edges are immune (the lift slides them
along themselves) - top edge only, exactly as reported; explains the
"also NOW" timing precisely.
Fix - draw space draws lifted, visibility stays physics (the f35cb8b
invariant, now symmetric):
- PortalVisibilityBuilder.Build gains drawLiftZ: the exit-portal branch
projects the OutsideView region with the lifted transform; flood
admission, side tests, and CellViews are untouched (default 0 keeps
every existing visibility test bit-identical).
- The seal/punch fans (DrawRetailPViewPortalDepthWrite) lift their
world verts to the drawn shell's space.
- One shared constant PortalVisibilityBuilder.ShellDrawLiftZ feeds the
shell registration (GameWindow:5604), the gate, and the fans.
Register: AP-32 ADDED - the +0.02 lift had NO row (a pre-register
deviation the 2026-06-12 sweep missed). The row records the split
invariant both ways: a draw-space consumer that forgets the lift
re-opens the #130 strip; a visibility consumer that picks the lifted
transform re-opens the #119-residual side-cull.
Pins: the lifted gate covers the drawn (lifted) aperture to 0.00 px
across the 147-combo sweep; the unlifted gate shows the 6.7 px strip
(sensitivity proof - if the lift is ever removed, this test says the
drawLiftZ plumbing can go too).
Suites: App 257+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user re-gate at a doorway with the lintel on screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's "doors/doorways leak through terrain and houses over a
landblock" is the #117 mark-pass bias evaluated in the wrong space.
Mechanism (confirmed analytically, Issue129PunchBiasTests): the punch's
pass-A stencil mark biased the aperture fan toward the viewer by a
CONSTANT 0.0005 NDC. NDC depth is non-linear - a constant NDC bias b
spans ~= b*d^2*(f-n)/(f*n) meters of eye depth at eye distance d. With
retail's znear 0.1 (d4b5c71) that is 0.125 m at 5 m but ~190 m at one
landblock: every hill/house in front of a distant aperture passed the
LEQUAL mark and was far-Z punched -> door-shaped leak through the
occluder. This is exactly the risk AD-18's register row recorded
("an occluder within ~bias in front of a distant aperture gets punched
through") - the symptom-scan rule found it before instrumentation.
Fix: cap the bias's EYE-SPACE span at 0.5 m -
biasNdc(d) = min(0.0005, capMeters * near / d^2)
in the mark-pass vertex shader (clipPos.w = eye depth), CPU-mirrored as
PortalDepthMaskRenderer.MarkBiasNdc for tests. Below the ~10 m
crossover the constant-NDC term is smaller and wins - bit-identical to
the T5-validated close-range behavior, so the #108 grass coverage that
justified the bias is untouched. Beyond it the punch can never reach an
occluder more than 0.5 m in front of the aperture plane.
Pins (Issue129PunchBiasTests): the old form spans >100 m of eye depth
at a landblock (the leak, kept as documentation of the refuted shape);
the capped form stays <= 0.5 m at every distance 1-400 m and matches
the validated constant bit-for-bit below 10 m.
AD-18 register row updated in the same commit (bias description + the
#129 closure + the residual risk note: door-hugging geometry beyond the
0.5 m cap at >10 m viewing range re-occludes - the cap constant is the
tuning knob if the gate shows residue).
Suites: App 256+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user visual gate at the original spot (+ #108 cellar
re-check up close).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's "thin strip of background color along the TOP outer edge of a
doorway, looking out from inside" is the landscape-slice scissor box, not
the W=0 clip port.
Mechanism (pinned headlessly, Issue130DoorwayStripTests, 147 eye/gaze
combos at the real Holtburg A9B4 0x0170 exit door):
- BeginDoorwayScissor converted the slice NDC AABB to pixels as
Floor(origin) + Ceiling(size). The far edge floor(min)+ceil(max-min)
lands up to ONE PIXEL SHORT of the true top/right edge at unlucky
fractional alignments (captured: top edge y=0.7938 @1080p -> row 968
cut; right edge column 1296 @1920 cut).
- The scissor brackets the ENTIRE landscape slice (sky, terrain, outdoor
statics, weather). The exit-portal SEAL stamps the full raw aperture at
true depth and the shell wall ends at the aperture edge, so the cut row
never receives any color write -> clear color, flickering with eye
movement as the fractional alignment shifts.
- This violated AD-17's own invariant (over-inclusion is safe,
UNDER-inclusion is the bug class). No register change: the fix restores
the row's documented doctrine.
Lead 1 (987313a W=0 clip port regression) REFUTED by the same harness:
the CPU polygon pipeline (ProjectToClip -> ClipToRegion merges ->
ClipPlaneSet planes) is sub-pixel exact against the raw aperture
projection (worst 0.54 px, 0.00 px aligned). For an all-in-front doorway
polygon the port is bit-identical to the old 1e-4 path by construction.
The EyeInsidePortalOpening rescue stays deleted.
Fix: conservative outer bound floor(min)/ceil(max) extracted to
NdcScissorRect.ToPixels (GL-free; containment property proven in the
header comment); BeginDoorwayScissor delegates.
Pins:
- NdcScissorRectTests: center-inside containment across 251 fractional
alignments x 2 framebuffer sizes + both captured regression cases.
- Issue130DoorwayStripTests: production flood + assembler at the real
exit door; asserts the scissor never cuts a plane-admitted fragment
(worstScissorGap 0.00 px post-fix, was 10.8 px capped) and the CPU
pipeline stays sub-pixel exact (canary 1.2 px).
Suites: App 252+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user visual gate at a cottage doorway.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 1ca412d part-offset expansion fixed the staircase but still rested
on the 5 m promise one level down: a SINGLE part whose mesh extends
more than 5 m from its own origin (offset 0 -> box +-5 m) keeps the
gaze-dependent vanish. Per the user's mandate ("it must work for every
case"), the bound now derives from the dat VERTEX data - the same
vertices that get drawn - so no synthetic containment promise remains.
Oracle context (read this session): retail has NO whole-entity
visibility volume - CPhysicsPart::Draw (0x0050d7a0) viewcone-checks
each part's dat-authored CGfxObj.drawing_sphere at the part's own
world position (RenderDeviceD3D::DrawMesh 0x005a0860). Retail's bound
IS data; ours was a promise. Our per-ENTITY granularity stays (a
deliberate batching-era choice, WB-owned per the inventory) but the
volume is now data-derived and conservative: visually identical by
construction, never culls what retail would draw.
- GfxObjBounds: per-GfxObj vertex AABB, cached by id (parts repeat
heavily); LocalBoundsAccumulator: union of part-transformed AABB
corners (conservative-correct under any affine transform).
- WorldEntity.SetLocalBounds + RefreshAabb preferred path: rotate the
root-local bounds' 8 corners into world axes + DefaultAabbRadius
margin (absorbs animated-pose drift vs the rest-pose bounds; keeps
small objects at their historical box size). Offset heuristic stays
as the fallback for boundless fixtures.
- All four hydration sites wired (outdoor stabs, scenery incl. baked
scale, interior cell statics, server live spawns).
Tests: tall-single-part coverage (the case 1ca412d could not see),
rotation-following, accumulator union. Suites: App 246+1skip / Core
1434+2skip / UI 420 / Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>