Commit graph

706 commits

Author SHA1 Message Date
Erik
ff29787f12 fix(D.2b): vitals from the real stacked-window LayoutDesc (0x2100006C)
The vitals bars were rendered from the WRONG layout. The ids in vitals.xml
(0x0600113x) belong to LayoutDesc 0x21000014 -- the 800x28 floaty side-vitals
ROW. The stacked vitals window the user sees is LayoutDesc 0x2100006C
(160x58), which uses a different sprite set and geometry. Dumped the real
tree (new dump-vitals-layout CLI, reflective) and ported it:

- Sprites (#2): the stacked-window set 0x0600747E-0x0600748F (health/stamina/
  mana, each back+front 3-slice; caps 10px, mid 130px).
- Right cap (#1) + fill model: retail UIElement_Meter::DrawChildren draws the
  back 3-slice full then the front 3-slice CLIPPED to the fill fraction (its
  own right-cap shows at 100%, the back's shows through when partial). UiMeter
  now clips the front per-slice (UV-crop) instead of growing a capless slice.
- Spacing (#5): three flush 150x16 bars at y=5/21/37 in a 160x58 window
  (16px pitch, zero gap), per the dat rects -- not the old 20px-apart guess.
- Border (#3): the window is the 8-piece chrome frame (corners 0x060074C3-C6,
  edges 0x060074BF-C2, 5px) -- dat-confirmed identical to RetailChromeSprites.

The headless render-vitals-mockup now composites this exact window
(0x2100006C) from the real sprites with the same clipped-fill model, so the
look was verified before launch. Font (#4, dat Font 0x40000000) is the next
commit.

Decomp refs: gmVitalsUI::PostInit @0x4bfce0; UIElement_Meter::DrawChildren
@0x46fbd0 (scissor-fill); geometry from LayoutDesc 0x2100006C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:50:17 +02:00
Erik
ada863980c feat(D.2b): scrollable retail chat window (read-only foundation)
Add UiChatView, a transcript widget for the retail-look UI: renders the
ChatVM tail bottom-pinned (newest at the bottom, like retail) with
mouse-wheel scrollback and whole-line vertical clipping so text stays
inside the frame. Hosted in a draggable/resizable UiNineSlicePanel and
wired into the UiHost next to the vitals window, fed by a dedicated
ChatVM (200-line tail) over the same live ChatLog. Per-ChatKind colour
palette (speech white, tells magenta, channels blue, system yellow,
emotes grey, combat orange).

This is the read-only foundation. The next sub-step adds glScissor
clipping + word-wrap, drag-to-select, and Ctrl+C copy -- the last needs
a CapturesPointerDrag opt-out on UiElement so an interior drag selects
text instead of moving the window (today an interior drag still moves
the window, same as the vitals panel).

Tests: UiChatView.ClampScroll (pin-to-bottom, cap-at-overflow,
never-negative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:12:12 +02:00
Erik
1453ff7da2 feat(D.2b): retail 3-slice vital bars + headless mockup verifier
Render each vital bar as a horizontal 3-slice from the real retail
RenderSurface sprites (authoritative ids from the vitals LayoutDesc
0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a
stretched glassy-gradient middle, and a fixed-width right-cap. The
empty back track draws full width; the coloured front fill grows from
the left to the value (the track owns the right end, so the fill omits
its own right-cap). Replaces the flat single-sprite Alphablend overlay
that read as the old UI - this is the bordered gradient look from the
retail screenshot (red HP / gold stamina / blue mana).

UiMeter gains the six 9-slice ids (BackLeft/Tile/Right +
FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the
backleft/backtile/backright/frontleft/fronttile/frontright attrs;
vitals.xml carries the 18 per-vital ids. The temporary
ACDREAM_BAR_PROVEOUT component grid is removed.

Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite
that assembles the bars with the SAME DrawHBar logic, so the sprite
assembly can be verified by eye (Read the PNG) without launching the
client + server - the fast UI-iteration loop the user asked for.
export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:11 +02:00
Erik
84630517e3 feat(D.2b): vital bars use retail dat sprites (back track + fill-cropped front)
UiMeter gains SpriteResolve/BackSpriteId/FrontSpriteId; when both are
set, OnDraw draws the empty-track sprite full-width then the colored-fill
sprite UV-cropped to the live fill fraction (left-to-right drain). Falls
back to solid rects when sprite ids are absent, keeping existing behavior
and tests intact.

MarkupDocument.Build() parses `back`/`front` hex attrs on <meter> and
passes `resolve` into every UiMeter.  vitals.xml wires the authoritative
LayoutDesc 0x21000014 sprites (Health 0x06005F3C/3D, Stamina 3E/3F,
Mana 40/41).  The bar prove-out block in GameWindow.cs was already gone.

If the sprites decode as 1x1 magenta at runtime they are paletted
(INDEX16/P8) — the solid-color fallback will display instead and can be
investigated separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:45:54 +02:00
Erik
b303baf4a1 fix(D.2b): windows not anchor-managed (regression: move/resize was reset each frame)
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>
2026-06-14 19:06:58 +02:00
Erik
f911b5f0af feat(D.2b): anchor layout — vital bars stretch with window; drop Vitals heading
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>
2026-06-14 18:58:58 +02:00
Erik
af91b8432a feat(D.2b): per-window resize-axis lock; vitals window is X-only (retail)
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>
2026-06-14 18:51:56 +02:00
Erik
0500646f08 fix(D.2b): draw UI chrome behind content (TextRenderer Flush layer order)
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>
2026-06-14 18:49:52 +02:00
Erik
de4f0167ef feat(D.2b): window resize (UiRoot edge-grip resize-drag mode)
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>
2026-06-14 18:27:57 +02:00
Erik
4acecffcd6 feat(D.2b): wire UiHost input + moveable windows (UiRoot window-drag + WantCapture gate)
- 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>
2026-06-14 18:02:27 +02:00
Erik
019350fa31 feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost
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>
2026-06-14 17:46:37 +02:00
Erik
07bf6cbf60 feat(D.2b): MarkupDocument (XML -> UiElement tree); vitals panel from vitals.xml
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>
2026-06-14 17:38:07 +02:00
Erik
97bd1d2f09 feat(D.2b): controls.ini stylesheet loader + apply title color
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>
2026-06-14 17:31:55 +02:00
Erik
b18403da02 feat(D.2b): wire UiHost + live retail Vitals panel (render-only); retire TS-30
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>
2026-06-14 16:56:57 +02:00
Erik
064ef41ce4 feat(D.2b): UiMeter vital bar + fill-geometry tests
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>
2026-06-14 16:38:07 +02:00
Erik
0bf790c8bf feat(D.2b): UiNineSlicePanel — 8-piece retail window frame + geometry test
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>
2026-06-14 16:36:11 +02:00
Erik
8e91805206 feat(D.2b): Step-0 chrome sprites confirmed + direct-RenderSurface upload path
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>
2026-06-14 16:32:27 +02:00
Erik
c9eef1d7cd feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite
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>
2026-06-14 14:28:29 +02:00
Erik
626d06ebc1 feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles
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>
2026-06-14 14:25:21 +02:00
Erik
6a9b529113 #119: entity bounds from dat vertex data - works for every case, not just multi-part
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>
2026-06-11 22:39:05 +02:00
Erik
987313aa54 knife-edge port: polyClipFinish W=0 eye-plane clip + degenerate-view propagation; EyeInsidePortalOpening rescue DELETED
Ports retail ACRender::polyClipFinish (0x006b6d00, pc:702749) near-eye
semantics into PortalProjection.ProjectToClip - the fundamental fix for
the in-plane portal clip family (climb strobes, tower-top roof/floor
flap while turning; live-corroborated this session: [viewer-diff]
0xAAB30108 strobing 27x mid-climb, whole interior dropping at the top).
Pseudocode: docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md.

Three legs, all decomp-driven:

1. ProjectToClip clips at w >= 0 EXACTLY (was EyePlaneW=1e-4), with
   retail's any-negative-w gate. Boundary intersections land at w == 0
   (homogeneous directions), so a portal the eye is CROSSING yields the
   correct unbounded half-region that the bounded view-region clip cuts
   to the screen. A w=0 vertex cannot survive a bounded region clip
   into the divide (direction fails some edge of any bounded convex
   region); the measure-zero corner case is guarded non-finite->empty.

2. CellView.CanonicalKey keys ALL-COLLINEAR (zero-area) views as their
   snapped segment ("L:" + extremes) instead of rejecting them - retail
   PROPAGATES degenerate views (ClipPortals decomp:433651-433711
   forwards any count!=0 GetClip output, no area gate anywhere), keeping
   the cell behind an exactly-in-plane portal in the draw list (cells
   draw whole; onward floods die naturally). Rejection dropped the
   whole chain for the frame - the parked-eye knife-edge band. Finite
   key space unchanged -> dedup + strict-growth convergence intact.

3. The EyeInsidePortalOpening rescue is DELETED (the T2-documented
   compensation for the 1e-4 divergence) along with EyeStandingPerpDist
   + PointInPoly2D. Empty clip = no flood, period (retail's rule).
   CornerFloodReplay - the gate that REFUTED the previous deletion
   attempt - passes WITHOUT the rescue under the W=0 port.

Harness criterion corrected to retail's rules (it codified the rescue):
cells fully BEHIND the camera are not required (all-behind portals clip
empty in retail); monotone area holds per root regime; the two
manufactured exact-on-plane steps assert root-only (boundary root pick
is ambiguous; the in-plane portal there is ~perpendicular to the gaze =
genuinely off-screen). Build_CollapsedInteriorPortalNearEye test
inverted to pin the retail empty-clip rule (it pinned the rescue).

New pins: eye-crossing portal -> w==0 boundary verts + half-region (not
sliver); gaze-along-plane degenerate view accepted + segment-key dedup;
non-finite guard. Replay harnesses (CornerFloodReplay, Issue120,
TowerAscent, HouseExit, Issue127) all green.

Suites: App 246+1skip / Core 1430+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:44:23 +02:00
Erik
2163308032 #119 ROOT CAUSE: interior-id X-byte collision + player-landblock cache hints = cross-entity batch serving
The decisive probe (3cf6bcc) caught it live in ONE session: a 43-part
staircase entity (src=0x020003F2, healthy MeshRefs tZ=[0.35..15.15])
drew with cache=hit:3 restZero=3 - THREE batches belonging to a 1-part
entity - then under a different hint the correct hit:119. Two
compounding bugs:

1. interiorIdBase = 0x40000000 | (landblockId & 0x00FFFF00) resolved to
   0x40YYFF00 for landblock keys 0xXXYYFFFF - the landblock X byte
   DISCARDED. Every landblock in a map Y-row shared one id space:
   Holtburg town A9B3's 9th interior stab == the AAB3 tower's spiral
   staircase, both 0x40B3FF09. Fixed to 0x40000000|(lbX<<16)|(lbY<<8)
   (the scenery 0x80XXYY## scheme).

2. The Tier-1 classification cache's #53 tuple key (EntityId,
   LandblockHint) was fed the PLAYER's landblock at bucket-draw time
   (RetailPViewRenderer.DrawEntityBucket fabricates its tuple with
   ctx.PlayerLandblockId), so colliding ids from different landblocks
   shared a key: whichever entity classified first under a hint won,
   and the loser wore its batches all session (static fast path never
   re-classifies). Also: bucket-hinted entries were never swept by
   InvalidateLandblock(owner) - stale entries survived owner unload.
   Fixed: ResolveCacheLandblockHint derives the hint from the entity's
   owning cell (ParentCellId landblock, canonical 0xXXYYFFFF), falling
   back to the tuple id for ownerless paths (outdoor stabs/scenery,
   where the tuple IS the owner).

Explains the session-shaped repro exactly: town-login + run to the
tower hydrates/classifies town interiors first -> the tower staircase
cache-hits the town twin's batches (stairs missing/partial + a wrong
object near the floor - the "water barrel"); login-inside classifies
the tower first -> usually clean. meshMissing=0 / entSeen==entDrawn
both ways (everything draws, wrong batches). Likely also feeds #113's
distance-dependent phantom staircase (the town twin wearing the
tower's staircase batches).

3 new cache tests pin the collision contract + hint derivation.
Suites: App green / Core 1430+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:43:45 +02:00
Erik
3cf6bcc219 #119 decisive probe: ACDREAM_DUMP_ENTITY one-shot entity dump (H-A/H-B/H-C discriminator)
The broken-state log (user-session-capture2.log) shows meshMissing=0 /
entSeen==entDrawn WHILE broken stairs are on screen - the staircase is
DRAWN WRONG, not missing. This probe discriminates the three live
hypotheses in ONE launch (handoff 2026-06-11 s4):

- HYDRATE dump (GameWindow.BuildInteriorEntitiesForStreaming): per-part
  placement-frame translations + dropped-part accounting at the MOMENT
  MeshRefs are constructed. H-A (SetupMesh.Flatten identity fallback /
  silent gfx-null part drops under degraded dat reads) shows here as
  zero translations or built<43.
- DRAW dump (WbDrawDispatcher, first tuple per entity): live MeshRefs
  translation summary + per-part loaded flags + Tier-1 classification
  cache state (batch count + RestPose translation summary), re-emitted
  compactly on signature change. H-B (partial/stale cached batch set)
  shows as correct translations + odd batch count.
- WALK-REJECT lines (rate-limited): attributes 'entity never reaches
  the draw loop' to the specific gate (visibleCellIds/frustum).
- Correct everything -> H-C (draw-side compose), instrument next.

Targets: ACDREAM_DUMP_ENTITY=0x020003F2,0x020005D8 (the 43-part spiral
staircase Setup + the wall barrels; H-A predicts the user's 'barrel' IS
the collapsed staircase). Probe is inert when the env var is unset.
Parser in RenderingDiagnostics (diagnostic-owner pattern) + 5 unit tests.

Suites: App 242+1skip / Core 1427+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:01:08 +02:00
Erik
7bbb169c6c #128 STRUCTURAL: missing meshes re-request their load at the POINT OF USE - permanent invisibility becomes impossible
The registration-time re-arm was insufficient and the user proved it
(ran back from the lifestone -> broken stairs + exposed barrel again):
a preparation cancelled by landblock churn AFTER the last registration
event has no later event to re-fire it - crossing blocks loads/unloads
them repeatedly behind the player, so the cancel-after-last-register
window is routinely hit on any cross-country run.

The structural fix: the draw dispatcher touches every
missing-but-referenced mesh every frame (the meshMissing slow path) -
THAT is the one site a retry can never be missed from. Both miss paths
(per-MeshRef and per-Setup-part) now call WbMeshAdapter.EnsureLoaded
(idempotent passthrough to PrepareMeshDataAsync, which early-outs on
existing data and dedups pending tasks), deduped per Draw pass.
Retail-equivalence: retail loads synchronously - geometry is never
permanently absent; this converges the async pipeline to the same
guarantee regardless of cancellation/eviction timing.

Also fixes the #53-one-level-deeper hole found en route: a missing
SETUP PART did not mark the entity incomplete, so a partial batch set
could cache permanently for Setup-shaped render data.

New apparatus: [mesh-miss] once-per-id line under ACDREAM_WB_DIAG=1 -
any future missing mesh names itself instead of needing a live repro.

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:30:56 +02:00
Erik
120aeff720 #126 RETAIL-CORRECTED: restores commit the server Z - retail never re-derives position from surfaces
The user caught the process failure: two snap fixes were written without
reading retails restore code. The named decomp settles it -
CPhysicsObj::SetPositionInternal (0x00515bd0, pc:283892-283945) treats
the supplied Position as INPUT: AdjustPosition resolves which cell
CONTAINS it, CheckPositionInternal/find_valid_position VALIDATES it
through the collision transition, and the no-cell case goes
store_position + GotoLostCell. There is NO terrain or surface
re-grounding anywhere in the restore path. Trust + validate.

Both prior shapes diverged: grounding outdoor claims to terrainZ warped
a roof-deck logout (ACEs authoritative z=127.2 on the AAB3 tower)
through the roof into the building volume -> the transparent-interior
spawn on every login; the cell-walkable scan that replaced it missed
shell-geometry decks entirely (no EnvCell owns the deck surface) and
failed silently - the user logged in transparent at the tower bottom
again.

Fix: a zero-delta outdoor restore above terrain commits the claims Z
verbatim ([snap] line says so); the first physics tick validates and
settles against the REAL collision world (the BR-7 building channel
covers the deck). max(terrain, z) stays as the under-terrain sanity
bound - our recoverable stand-in for retails lost-cell machinery
(documented divergence, same class as the #107 demote).

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:22:17 +02:00
Erik
f35cb8b164 #119-residual ROOT CAUSE: the +0.02 m render lift leaked into the portal-visibility graph - horizontal portals side-culled anyone standing on them
The live capture pinned it end to end. BuildInteriorEntitiesForStreaming
lifts the render-side cell transform +0.02 m Z (shell z-fighting vs
terrain - a DRAW concern) and passed that LIFTED transform to
BuildLoadedCell, so every plane in the visibility graph sat 2 cm high.
The portal side test's in-plane window is +-10 mm: an eye standing ON a
floor containing a HORIZONTAL portal (the tower's deck lip 010A->0107,
stair landings, cellar mouths) sits 0-10 mm above the TRUE plane = 10-20
mm BELOW the lifted plane -> outside the window -> the cell behind the
portal side-culled out of the flood. Captured live at the stair top:
root=0xAAB3010A eye z=126.803 vs the portal plane at 126.80, flood=1,
0x0107 (the whole tower interior incl. the staircase) dropped WHILE THE
GAZE LOOKED STRAIGHT AT IT - "stairs disappear and you can walk on
them", and the roof/edge flap as the gaze swung the marginal admissions.
Vertical doorways were immune (the lift slides their planes along
themselves) - exactly why this hit stairs/decks/floors and not doors.

Chase chain (the apparatus did all the work): [viewer] print-on-change
probe with eye@mm -> the user's climb capture -> [viewer-diff] naming
the dropped cells per flip -> headless replay of the exact captured
(eye,fwd) frame: healthy UNLIFTED, reproduces ONLY with the production
lift -> gate-by-gate diagnostic (side test dot=+0.003 unlifted vs
-0.017 lifted; clip + rescue exonerated; knife-edge z-sweep all-stable,
killing the float-chaos theory).

Fix: BuildLoadedCell receives the PHYSICS (unlifted) transform; the
drawn shells keep their lift. The seal/punch fans (which read the
visibility LoadedCell's WorldTransform) now stamp TRUE depth - MORE
consistent with the unlifted terrain they protect.

Pins: CapturedTopOfStairs_MainCellStaysInFlood - arm 1 (unlifted =
post-fix production) asserts the main cell admitted at the captured
frame; arm 2 (lifted) is the mechanism canary asserting the drop, with
instructions if it ever starts passing. Plus the gate-by-gate
diagnostic + knife-edge sweep as the investigation record.

Also this session: Issue127FloodFlipReplayTests (the captured 4 cm
outdoor flip pair replays STABLE across fovs/pre-gate arms - the
outdoor churn is NOT the flood math; remaining #127 = distant-building
admission churn, lower priority now that the tower-cell drops are
explained by the lift), and the [viewer-diff] probe (per-flip added/
removed cell naming - keep, it found this).

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:26:06 +02:00
Erik
cd12d3dbbc capture run decoded: #126 spawn-through-roof + #127 bistable flood admissions + #128 session-sticky invisible staircase filed; [viewer] probe gains fwd=
The users tower capture (tower-viewer-capture.log, 551 [viewer] lines)
decodes into three distinct issues:

- #126 (HIGH, #107/#111 family): an OUTDOOR spawn claim on the tower
  roof (z=127.2) is grounded to TERRAIN z=112 - the player is warped
  through the roof into the tower interior, outdoor-classified ->
  the transparent-interior spawn. The snap outdoor branch must ground
  to the nearest WALKABLE surface (roofs/GfxObj floors), not terrain.
- #127 (HIGH, the flap mechanism): per-building flood admissions are
  BISTABLE per frame under the outdoor root - flood size oscillates
  +-1-3 cells at millimetre eye deltas (45<->52 standing on the roof,
  including a byte-static eye flip). Every oscillation = building
  interiors dropping in/out -> the roof/edge flap; running past a
  building = #123. Interior side shows the same family (flood 1<->3,
  outPolys 0<->1 during the climb).
- #128: the staircase was invisible the WHOLE climb under a HEALTHY
  interior root (0xAAB30107 FullScreen views - the cone cannot cull a
  root-cell static), while the SAME build rendered it perfectly in a
  different session (diag spawn + screenshot, meshMissing=0).
  Session-sticky nondeterminism; the barrel tracks this bug (a
  partial subset of staircase parts), NOT dat content (user axiom:
  no barrel in retail). Needs a diag-instrumented repro of the users
  session shape.

The [viewer] probe now logs the camera forward (fwd=) so the next
capture can be replayed headlessly - Build clip results depend on the
view-projection, not just the eye.

Suites: App 238+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:53:17 +02:00
Erik
a974504e6e #119-residual: [viewer] capture probe - the capture half of the tower capture-replay loop
One line per change of (root cell, flood size, OutsideView polys, player
cell), with the projection eye at mm precision on every line
(ACDREAM_PROBE_VIEWER=1, print-on-change, silent while stable). The
tower-ascent harness replays the captured production (eye, root) pairs
deterministically - replacing the synthetic helix that proved unphysical
in the roof-lip band (the real collided camera may never reach it).

Suites: App 238+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:41:05 +02:00
Erik
fcade06c46 #125: gpu_us query ring reads never-begun query objects - root cause of the WB_DIAG GL error cascade; fixed + live-verified
Root cause (by read, verified live): a glGenQueries name does not become
a QUERY OBJECT until its first glBeginQuery - GetQueryObject on a
never-begun name is GL_INVALID_OPERATION. The N.6 gpu_us ring assumed
ONE dispatcher Draw per frame with both passes always non-empty; the
pview pipeline issues MANY small Draws per frame (landscape slices,
per-cell static buckets, dynamics), where zero-draw passes routinely
skip BeginQuery. Under ACDREAM_WB_DIAG=1 the slot read queued an
InvalidOperation EVERY frame - silently, until WB's diligent
texture-path glGetError checks ate the stale errors and treated their
own successful uploads as failures ([wb-error] + the sticky drop) and
ProcessDirtyUpdates' check threw (process death, tower-wbdiag3.log).
The GL-error-attribution trap, textbook form.

Fix: begun-flags per ring slot per target; the read path only queries
slots that were actually begun (a skipped pass contributes 0 ns).
Live verification (tower-wbdiag4.log, in-tower spawn): zero [wb-error]
(was 7), no crash, gpu_us reads real values (9-11 us) for the first
time under the pview pipeline, meshMissing=0 / entSeen==entDrawn.

Consequences: (1) the #119 missing-stairs mechanism theory via sticky
GL upload failures is RETIRED for normal runs (WB_DIAG off = no query
calls = no errors; clean runs confirmed zero wb-error) - and the
in-tower screenshot on the current build shows the spiral staircase
RENDERING, so the stairs were most plausibly a #120 flood-corruption
casualty (the tower threshold cells portal back to 0x0107 exactly in
the ping-pong window); user verdict pending. (2) The sticky-drop
defect (upload failure never retried) stays filed under #125 as
defense-in-depth debt - the trigger is gone but the design flaw isn't.

Suites: App 236, Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:19:55 +02:00
Erik
63d14c3d6b #125 filed + WB-DIAG median crash fixed - the #119 stairs mechanism is a sticky GL upload failure
The in-tower ACDREAM_WB_DIAG launch (the saved character spawns inside
the #119 tower - a free deterministic repro lever) produced the
mechanism evidence in one run (tower-wbdiag3.log):

1. [wb-error] upload of 0x0100321D died on a GL InvalidOperation in
   ManagedGLTextureArray..ctor (new TextureAtlasManager) - caught,
   returns null, and the drop is STICKY: _preparationTasks.TryRemove
   runs BEFORE the upload, so a failed upload is never re-prepared.
   Permanently invisible mesh, one log line. This failure class is the
   likely #119 missing-stairs mechanism (dat + extraction +
   registration + dispatcher all exonerated by read/test this session).
2. The SAME GL error then fired UNCAUGHT in Tick -> GenerateMipmaps ->
   ProcessDirtyUpdatesInternal and killed the process. Both render-
   thread - not thread affinity. Filed as #125 (HIGH) with the open
   question of GL error attribution (a stale error queued by an earlier
   unchecked call lands on WB's diligent glGetError checks).

Also fixed here: WbDrawDispatcher.MedianMicros crashed with
IndexOutOfRange on the first diag flush when exactly 1 sample was
recorded (copy[copy.Length - nz/2] with nz==1) - the same off-by-one
GameWindow's TerrainDiagMedianMicros twin fixed; same fix applied.
ACDREAM_WB_DIAG=1 is usable again.

Suites: App 236, Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:08:46 +02:00
Erik
c4464739d2 #121: dynamics-owner particle pass - world portals visible again; re-gate ledger in ISSUES
Fix: dynamics' ATTACHED emitters (portal swirls on server-spawned portal
entities, creature effects) fell through EVERY particle filter under the
unified pview path - the landscape slice filter carries outdoor statics
(+ the #118 outside-stage dynamics), the per-cell callback carries cell
statics, and T4 deleted the clipRoot==null global pass from normal
frames. T5 never checked portals; the user's re-gate caught it ("all
portals that were previously showing are now gone"). DrawDynamicsLast
now hands its cone-surviving dynamics (minus outside-stage entities,
whose emitters already drew in the landscape slice - alpha particles
must not double-draw) to a new DrawDynamicsParticles callback;
GameWindow draws Scene-pass emitters filtered to those owner ids,
mirroring DrawRetailPViewCellParticles. Retail shape: emitters draw
with their owner object.

Re-gate ledger (user verdicts are axioms):
- #117 CLOSED ("Yes solved"), #118 CLOSED ("Yes solved" + NPC-through-
  door "Yes fixed").
- #108 REOPENED narrowed: cellar-ascent eye-below-grade window only
  (grass covers the exit door until the head pops over ground level);
  fix belongs on the membership/viewer side - the depth-gated punch
  stays (DO-NOT-RETRY).
- #119 user split: phantom walkable stairs at the hill cottage (#113
  family), tower missing stairs + barrel (#119 proper), hill-house
  transparent-on-entry (#112 - re-check after the #120 fix; the
  ping-pong fired at exactly A9B3 0103/010F).
- #120 FIXED pending re-gate (dede7e4).
- NEW #122 window oscillation on entry (re-check after #120 first),
  NEW #123 buildings transiently disappear running close past,
  NEW #124 far-building back walls missing through openings (lead:
  per-building look-in floods run only for outdoor roots -
  NearbyBuildingCells is null for interior roots; retail runs the
  look-in inside LScape::draw for ANY root).

Suites: App 236, Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:36:58 +02:00
Erik
dede7e491c #120: CellView containment rejection - the reciprocal ping-pong converges; tripwire firings reproduced + killed
The armed tripwire self-attributed on the re-gate launch
(regate-118-119-launch.log): a pure TWO-CELL reciprocal ping-pong, 64 laps
each - chain root=0xA9B4015C eye=(109.995,37.158,96.249) cells 0162x64
015Cx64, and root=0xA9B3010F eye=(175.771,-107.310,118.814) cells 0103x64
010Fx64 (A9B3 = the hill cottage the user reports going all-transparent on
entry - likely the same mechanism, verify at the next gate).

Mechanism: with the eye within PortalSideEpsilon (+-1 cm; the T2
refuted-to-tighten root-lag tolerance - retail's is 0.0002) of a portal
plane, the in-plane case counts as interior for BOTH cells, so views lap
A->B->A...; each lap re-clips through two near-edge-on apertures whose
intersection numerics wobble by more than CellView's 1e-3 dedup grid, so
every lap keys as NEW and the in-place growth recurses to the depth-128
failsafe. The prior convergence sweeps could not reproduce because they
only load the corner building 0x016F-0x0175 - both firing pairs are
outside that set. Issue120ReciprocalPingPongTests loads the full
landblock's interior cells and drives the +-epsilon window directly:
deterministic firings + 65-polygon CellView piles pre-fix.

Fix (the handoff's own predicted class - dedup admitting near-duplicates
per lap, NOT a limit tune): CellView.Add rejects a polygon CONTAINED in
one already stored (convex edge test, DedupGridNdc slack). A round-trip
re-emission is, in exact arithmetic, a SUBSET of the polygon that
originated it - containment rejection makes union growth strictly
area-increasing, so no new visible area means no propagation. Bonus:
back-emission into a full-screen view (the root cell) now always rejects.
The corner-flood completeness pins stay green - no real region is dropped.

Suites: App 236 (232+4), Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:32:21 +02:00
Erik
8d93665053 #119: the [up-null] lead is EXONERATED (dat-proven) - both GfxObjs are legitimately no-draw models
Issue119UpNullGfxObjDumpTests pins the dat truth: 0x010002B4 = 9 polys,
ALL NoPos, all surfaces Base1Solid; 0x010008A8 = 1 poly, NoPos,
Base1Solid|Translucent. Retail's skipNoTexture never draws either model
(the BR-1 build-time-skip <=> draw-time-skip equivalence), so
ObjectMeshManager's empty render-data cache is the CORRECT terminal state
- the only defect was the alarming "permanently invisible" log line,
reworded into an honest tripwire pointing at the dump test.

Second fact, same test (ShellModel_NoTexturedPolyIsDropped): on the
hall/tower shell 0x010014C3, ZERO textured polys are dropped by the
extraction gates (137/149 draw; the 12 dropped are the known #113
no-draw orphans) - the per-poly GfxObj extraction is exonerated for
building shells, kept green as a regression pin.

Net for #119: the missing tower-stair parts are NOT the up-null pair and
NOT a per-poly extraction drop. Remaining hypothesis space (interior
stair-cell flood admission, or a different model than assumed) needs the
re-gate to identify the exact tower; then the cell set + flood replay
headlessly like #118. ISSUES.md updated.

Suites: App 232, Core 1419+2skip (1416+3 new), UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:55:45 +02:00
Erik
5a80a2ee24 #118: outdoor dynamics draw in the outside stage under interior roots - the house-exit clip+vanish was the SEAL z-killing the player
Root cause (pinned by the new deterministic exit-walk harness, NOT guessed):
under an interior render root, the exit-portal SEAL stamps the door fan at
TRUE depth after the gated full depth clear, and T1's "ALL dynamics last"
pass then drew the outdoor-classified player depth-tested - every fragment
beyond the door plane z-failed against the seal across the whole aperture.
Harness measured the full window: from the moment the sphere center crosses
the plane until the eye follows (~2.6 m of camera lag, ~2.2 s at walk speed)
the player is invisible; while straddling, the beyond-plane body half clips
at the plane. The handoff's three cone-level candidates are all EXONERATED:
the cone walk passes every step; (eye, ViewerCellId) come from the same
SweepEye call with camera-update-before-visibility-read in the same frame;
the side-test window is sub-epsilon under healthy resolution.

Retail oracle (grep-named-first): PView::DrawCells 0x005a4840 runs
LScape::draw FIRST (pc:432719), then the gated depth clear (pc:432731-32)
and the exit-portal seals (pc:432785-86); outdoor cell objects draw inside
the landscape stage (DrawBlock 0x005a17c0 -> DrawSortCell pc:430124), and
an object draws once per overlapped shadow cell (pc:430056-64) - the
straddling body composes from both stages, neither half clips.

Fix: RetailPViewRenderer assigns dynamics to the OUTSIDE stage under an
interior root when outdoor-classified OR sphere-straddling an exit-portal
plane of their flood-visible cell (DynamicDrawsInOutsideStage - pure, the
harness drives it as the ordering contract); they ride the landscape slice
draw (pre-clear, seal-protected) with the same per-slice cone test as
outdoor statics. Indoor dynamics keep the last pass (retail loop C);
straddlers draw in both (retail shadow dual-draw). Outdoor roots keep
all-dynamics-last - the BR-2 punch-after-dynamics lesson (88be519) stands.

Apparatus: HouseExitWalkReplayTests - dat-backed corner-building exit walk
driving the production stack headlessly (RetailChaseCamera damping ->
healthy-sweep viewer resolution -> PortalVisibilityBuilder.Build ->
ClipFrameAssembler -> ViewconeCuller -> the DrawDynamicsLast predicate +
a CPU seal-depth model). 5 tests: cone pin, seal-depth pin, straddle
dual-draw pin, per-step table, stale-root window quantifier (#118 cand 2).

Suites: App 232 (227+5), Core 1416+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:49:29 +02:00
Erik
478c549b9e #117: depth-gate the aperture punch - stencil mark+punch (z-buffered equivalent of retail's painter's order)
T5 reported doors/interiors visible through terrain hills and through
nearer buildings, always in aperture-shaped regions. Root cause, decomp-
settled: retail's DrawPortalPolyInternal (Ghidra 0x0059bc90) draws the
punch with DEPTHTEST_ALWAYS + per-vertex far-Z (0.99999899, maxZ1 bit0)
- it UNCONDITIONALLY stomps any occluder depth at aperture pixels.
Retail is safe only because its outdoor pass is painter's-ordered
far->near: anything nearer (hills, closer houses) draws AFTER the punch
and re-covers it. Our z-buffered MDI frame has no such global order
(one terrain pass + one shells pass), so the faithful GL-state port of
the punch was unsafe by construction - the far house's aperture punch
erased the near house's wall depth / the hill's depth, and the interior
+ door entities (dynamics drawn last) painted through.

Fix - the z-buffer-correct equivalent of the painter's-order guarantee:
punch only where the aperture polygon itself is VISIBLE.
PortalDepthMaskRenderer's punch path is now two passes:
  A) stencil-mark: aperture fan at its (slightly biased) true depth,
     depth LEQUAL, no depth write -> stencil=1 where the aperture wins
     against everything drawn so far (terrain + all shells precede
     DrawExitPortalMasks in the frame, so the buffer holds the real
     occluders);
  B) far-Z punch with depth ALWAYS, stencil-gated EQUAL 1, zeroing the
     stencil as it goes (self-cleaning; no frame-level stencil state).
The mark bias (0.0005 NDC ~ 6 cm at 5 m) keeps #108's case covered:
terrain hugging the door plane still punches; a hill or another house
meters nearer no longer does. The SEAL path (interior roots) stays
retail-verbatim single-pass - it runs right after the gated full depth
clear, so there is nothing nearer to stomp.

Also: WindowOptions now requests 8 stencil bits explicitly (was the
GLFW platform default), and PortalDepthMaskRenderer's stale "RESERVED -
not wired" banner is corrected (T1 wired it via
DrawRetailPViewPortalDepthWrite).

Acceptance rides the focused post-T5 re-gate (downhill door check +
behind-house openings check + #108 cellar stays clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:05:48 +02:00
Erik
2d15084243 #120: arm the propagation tripwire for self-attribution + two convergence regression pins
Investigation: retail's growth propagation RECURSES natively too
(AddViewToPortals -> FixCellList -> AdjustCellView -> AddViewToPortals,
Ghidra 0x005a52d0/0x005a5250/0x005a5770, no depth guard) - the in-place
recursion shape is faithful; retail's safety is fast convergence. Our
depth-128 firing means slow/non-saturating growth (each lap of a portal
cycle nests one recursion level), not necessarily a true infinite loop.

Two dat-backed sweeps over the corner-building cell set could NOT
reproduce the T5 firing:
- PortalPlaneCrossings_InPlacePropagationConverges: +/-6cm eye sweep
  across every portal plane, seeded from both sides.
- InCellDirectionSweep_InPlacePropagationConverges: 3024 builds, in-cell
  eye grid x 8 yaw x 3 pitch (the walking-and-turning regime).
Both pass with 0 firings -> production-only ingredients suspected (full
lookup graph - one T5 firing was 0x0162, another building - and/or the
real camera path).

Armed: PortalVisibilityBuilder.ConvergenceTripwireCount (test
observable, both Build + look-in sites) + DumpPropagationChain - on the
next firing the log carries root cell, eye, per-cell frequency summary,
and the 24-entry chain tail, so the cycle's structure (A<->B ping-pong
vs 3-cycle laps) reads directly off the output. Both sweeps stay as
regression pins.

App tests: 227 green (was 225; +2 pins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:57:25 +02:00
Erik
dbfbf8506c T6 (BR-7) C3: per-cell shadow architecture - flood registration, building channel, per-cell query; b3ce505 stopgap DELETED (closes #99)
The A6.P4 port, fused into one installment per the BR-2 half-port lesson
(registration and query are co-dependent: flood-registering shells under
the old radial query would re-open #98 through the vestibule).

REGISTRATION (ShadowObjectRegistry rewritten):
- Register/RegisterMultiPart/UpdatePosition compute the cell set via
  CellTransit.BuildShadowCellSet (the C2 find_cell_list flood) seeded by
  the entity's m_position cell id; the private 24m XY-grid rectangle and
  its single-landblock clamp are deleted. Flood spheres follow retail's
  CylSphere rule (base point + cyl radius, cap 10; BSP bounding-sphere
  fallback - Ghidra 0x0052b9f0). Statics flood with the do_not_load
  prune; dynamics (server spawns, isStatic:false) without.
- Keep-when-empty (SetPositionInternal num_cells gate, pc:283540): a
  failed flood leaves the previous registration in place.
- RefloodLandblock: streaming-race hook re-runs the flood when a
  landblock's cells hydrate (retail init_objects -> recalc_cross_cells,
  Ghidra 0x0052b420/0x00515a30); wired at GameWindow's hydration tail.
- GameWindow sites pass the server position's full cell id as the seed
  (spawn + UpdatePosition); the five static sites pass ParentCellId.

BUILDING CHANNEL (CSortCell.building shape):
- Building SHELLS are not shadow objects in retail (only caller of
  find_building_collisions is CSortCell::find_collisions 0x005340aa;
  one building per origin landcell, init_buildings 0x0052fd80 verified
  verbatim + ACE cross-ref). IsBuildingShell entities skip the registry;
  Transition.FindBuildingCollisions runs the shell part-0 BSP off
  cache.GetBuilding(cellId) with bldg_check set around it
  (find_building_collisions 0x006b5300), CollidedWithEnvironment on
  non-Contact non-OK. BuildingPhysics.ModelId = pre-resolved part-0
  GfxObj (0x02 Setups resolved at the CacheBuilding site).
- Placement/ethereal weakening: BSPQuery Path 1 passes center_solid=0
  when BldgCheck && HitsInteriorCell (BSPTREE::find_collisions 0x0053a82e
  + placement_insert 0x005399d8) so doorway crossings don't hard-fail
  against shell solids. SpherePath gains both retail fields;
  HitsInteriorCell is rebuilt at every cell-array build
  (build_cell_array reset 0x00509ef2 + find_cell_list/check_building_
  transit set sites).

QUERY (retail per-cell order, transitional_insert 0x0050b6f0):
- TransitionalInsert per attempt: env -> building (LandCell only) ->
  objects on the PRIMARY cell, then on OK the check_other_cells pass
  (env -> building -> objects per OTHER overlapped cell) + the
  carried-cell advance - the advance now happens AFTER all per-cell
  object passes (the WF1 ordering divergence), with Adjusted/Slid
  feeding the retry exactly like retail's OK_TS case.
- FindObjCollisionsInCell = CObjCell::find_obj_collisions (0x0052b750):
  iterate ONLY the asked cell's list. DELETED: the radial 9-landblock
  sweep, the +5m query pad, the b3ce505 indoor-primary gate, and the
  isViewer exemption (the camera is bounded by interior cell-BSP env
  collision - retail's own channel; CameraCornerSealReplayTests pins it
  against real dat, and the new building-channel camera test pins the
  outdoor stop).

TESTS: Core 1416/0/2 (was 1398 + 4 pre-existing #99-era fails + 1 skip),
App 225, UI 420, Net 294 - all green.
- 3 of the 4 #99-era reds flipped green as designed: the door apparatus
  (Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks) and tick-13558
  (indoor walkthrough) now assert the door BLOCKS; tick-22760 pins the
  outdoor blocking invariant.
- The 4th (BSPStepUp D4) + 22760's lateral-slide delta are NOT cell-set
  problems (probes prove the door is found + BSP-only dispatched;
  BR-7 left both byte-identical) - filed as issue #116 (slide-response
  family), D4 skipped with the issue reference.
- FindEnvCollisionsMultiCellTests migrated to the public entry (the A4
  multi-cell halt now lives at the retail call site).
- New registry pins: per-cell query surface, outdoor-footprint-never-
  indoor (#98 architectural), door-outdoor-cell membership, reflood.
- CameraCollisionIndoorTests rewritten against the building channel
  (the isViewer-exemption pins died with the exemption).

Closes #99 (doors block both ways via registration-time cell membership
+ the straddle-spanning player cell array). #97 likely closed (the +5m
radial pad that produced phantom-collision candidates is gone) - verify
at T5. #98 stays closed ARCHITECTURALLY (outdoor footprints structurally
cannot reach interior cells; the cellar harness stays green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:37:50 +02:00
Erik
6ec4cde9a4 T6 (BR-7) C1: signed OtherPortalId + the >=0 building-transit gate
Retail CEnvCell::check_building_transit (Ghidra 0x0052c5d0) opens with
`if (other_portal_id >= 0)` on the SIGNED sign-extended portal id
(CBldPortal.other_portal_id is int, acclient.h:32098). Our BldPortalInfo
carried the dat reader's raw ushort and CheckBuildingTransit had no gate
at all, so a portal whose dat value is 0xFFFF (-1, "no reciprocal
portal") could admit its interior cell. BN's pseudo-C renders the
comparison unsigned - the sign-extension is Ghidra-proven (BR-7 verified
corrections, wf1-interior-collision.md).

- BldPortalInfo.OtherPortalId: ushort -> short; GameWindow construction
  reinterprets the dat ushort via unchecked((short)).
- CheckBuildingTransit: negative-id portals rejected before any sphere
  test; new multi-sphere overload matching retail's per-sphere loop
  (0052c5fe, first-hit admits) with the hits_interior_cell output
  (0052c650) the BR-7 building channel consumes next.
- Tests: negative-id skip vs positive-id admit on a leaf-root CellBSP;
  multi-sphere plumbing + zero-sphere no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:56:16 +02:00
Erik
4a307d33b5 T4 (BR-6): ONE visibility gate - ACME BFS deleted from the frame, legacy second render path deleted
The one-gate rule (feedback_render_one_gate) is now structural:

- The per-frame ACME BFS (CellVisibility.ComputeVisibilityFromRoot) is
  GONE from the frame. Its only production consumer was the
  cameraInsideCell boolean - which is exactly 'viewerRoot is not null'
  (the TryGetCell that produced viewerRoot already proves cells are
  loaded; ComputeVisibilityFromRoot returned null iff root was null).
  A full second visibility computation ran every frame to derive a
  boolean we already had. The method + its tests remain as quarantined
  non-production code (dual-live-visibility-computations, confirmed).

- The clipRoot==null mini-pipeline is DELETED (legacy-outdoor-branch-
  remnant, adjusted-confirmed): the outdoor partition draw, the
  Chebyshev look-in gather, the DrawPortal invocation and the dynamics
  fallback. clipRoot is null only when NO viewer cell exists (pre-login,
  fly/debug cameras, transient gaps) - those frames draw flat through
  the dispatcher; every normal outdoor frame is the outdoor node.

- DELETED with it: InteriorRenderer (class file - its only caller was
  the legacy branch), RetailPViewRenderer.DrawPortal +
  RetailPViewPortalDrawContext (the look-in product; outdoor-root frames
  flood buildings via MergeNearbyBuildingFloods inside DrawInside),
  the _exteriorPortal*/_outdoorRootNoCells fields.

Per frame there is now exactly ONE visibility computation
(PortalVisibilityBuilder) and ONE render path (DrawInside).

Suites: build green, App 226 green, Core baseline (1398 + 4 pre-existing
#99-era).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:03:06 +02:00
Erik
a6aec8c32f T3 (BR-5): viewconeCheck port - per-view sphere culling for statics/dynamics/particles, weather player-gate, unattached outdoor emitters
Ports Render::viewconeCheck (Ghidra 0x0054c250): meshes are sphere-CULLED
per portal view, never hard-clipped. NEW ViewconeCuller lifts each
slice's <=8 clip-space half-planes to world-space eye-edge planes (the
view_vertex.plane analog, acclient.h:32483 - one matrix fold: L = VP
rows . P) and tests bounding spheres from the entity's cached AABB (the
dispatcher's own cull bounds source).

Gating now matches retail's shape end to end:
- Per-cell STATICS: sphere vs THEIR CELL's views - the statics-through-
  walls fix (the cottage phantom staircase's actual draw path: a static
  outside every view of its cell no longer paints through the wall).
- DYNAMICS (last pass): sphere vs their cell's views; outdoor/unresolved
  vs the outside views (pass-all under the outdoor root). A dynamic in a
  non-flooded room culls HERE - retail never reaches an object whose cell
  is not in the draw list; the partition still routes it so the CULL is
  what drops it, retail's shape exactly.
- OutdoorStatic (landscape pass): pre-filtered per outside slice; the
  per-slice entity gl_ClipDistance routing is DELETED (entities draw
  outside the clip bracket; terrain/sky keep their plane clip).
- PARTICLES: the scissor-AABB gate is DELETED; emitters gate through
  their cone-surviving owners (candle-flames-through-walls fix).
- WEATHER: gated on the PLAYER being outside (retail is_player_outside -
  an indoor player gets no rain even looking out a doorway). Closes
  weather-gate-player-vs-viewer.
- UNATTACHED emitters (campfires) get their missing outdoor-root pass
  (closes unattached-particles-dropped-outdoors).

Suites: App 226 green (flood gates included), Core baseline unchanged
(1398 + 4 pre-existing #99-era).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:56:48 +02:00
Erik
88f3ce1fa0 T2 slice 3 (BR-4): draw-driven flood gating - per-building frustum pre-gate, 48m seed cap DELETED
Retail floods a building's interior exactly when its shell DRAWS and an
aperture survives the view: DrawBuilding (Ghidra 0x0059f2a0) -> per-view
viewconeCheck on the shell -> portal-BSP walk -> ConstructView(CBldPortal)
side test + GetClip-vs-view + GetVisible. There is NO distance constant
anywhere on that chain (verifier-confirmed, flood-gate-shape adjusted).

Port:
- GameWindow's outdoor-node gather: per-BUILDING frustum pre-gate on the
  aperture bounds (Building.PortalBounds - the tight flood-purposes
  equivalent of the shell viewconeCheck), iterating the per-landblock
  BuildingRegistries. Replaces the Chebyshev<=1 landblock cell-sweep.
  Also the proper fix for the 2026-06-07 'FPS drops when I look out'
  problem the Chebyshev hack approximated: dozens of AABB tests instead
  of an O(all loaded cells) portal sweep.
- OutdoorBuildingSeedDistance 48f -> infinity (the binary visibility pop
  at ~48 m - the confirmed #109 mechanism candidate - is gone; admission
  is now the screen clip per portal, retail's GetClip gate).
- The legacy clipRoot==null look-in path keeps its 48 m: it is T4
  deletion scope; improving doomed code wastes effort.

Closes the building-flood-seeding-48m-cutoff divergence (culling area,
adjusted-confirmed). Suites: App 226 green (flood gates included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:46:46 +02:00
Erik
529dfcfee9 T2 slice 2 (BR-4): in-place growth propagation, strict reciprocal cull, retail seed in-plane reject - two retail constants REFUTED by the gate and documented
IN (retail-faithful):
- Growth propagation is IN PLACE, never by re-enqueue: retail
  AddViewToPortals (Ghidra 0x005a52d0) enqueues only on first discovery
  (InsCellTodoList); growth into a popped cell runs AdjustCellView -
  re-clip ONLY the new views through that cell's portals immediately.
  ProcessCellPortals + the processedViewCounts watermark port exactly
  that; the MaxReprocessPerCell=16 drift cap is DELETED (the 1-px merge
  is the physical fixpoint floor). Depth-128 tripwire logs loudly if the
  convergence invariant ever breaks (failsafe, not control flow). Same
  restructure in BuildFromExterior.
- Reciprocal-empty culls strictly (retail OtherPortalClip returning
  nothing = the opening is invisible from the neighbour's side); the
  eye-in-opening pre-clip restore is gone.
- Look-in seeds: retail ConstructView(CBldPortal) IN_PLANE reject at the
  TRUE F_EPSILON (SeedInPlaneEpsilon=0.0002, const @0x007c8c70) + the
  full-screen substitute rescue DELETED (the verifier-flagged non-retail
  bypass that admitted floods retail strictly rejects).

REFUTED BY THE CONFORMANCE GATE (attempted, reverted, documented inline):
- PortalSideEpsilon 0.0002: retail's tight epsilon assumes the viewer
  cell transits the instant the eye crosses a plane; our root can lag
  ~1 cm at pressed corners (CornerFloodReplay failed at every step -
  0x0171/0x0173 chain dead). 0.01 KEPT as the documented root-lag
  tolerance; tighten only with eye-exact viewer tracking + cdstW.
- Deleting the clip-empty eye-in-opening rescue: same gate, same total
  failure - our ProjectToClip near-eye behavior (EyePlaneW=1e-4) diverges
  from retail polyClipFinish's UNPINNED cdstW constant. Rescue KEPT as
  the documented cdstW-gap compensation; re-attempt only after pinning
  cdstW from the binary.

Gates: App 226/226 green (CornerFloodReplay + MeetingHallFlood + the
collapsed-portal pin all pass); Core baseline unchanged (1398 + 4
pre-existing #99-era). CloneViewPolygons orphan removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:42:41 +02:00
Erik
cf8a2c379b T2 slice 1 (BR-4): multi-view UNION merge + retail 1-px vertex merge (the fixpoint floor)
(a) MergeBuildingFrame now UNIONS a building flood's views into cells
already present in the frame (retail Render::copy_view APPENDS every
clipped portal polygon as a new view_poly, Ghidra 0x0054dfc0 - a cell
visible through two apertures holds two views). The old first-wins
'ContainsKey -> continue' dropped the second aperture's views: the
multiview-loss-first-wins divergence, a named #109 suspect.

(b) ClipToRegion output now runs retail's post-divide vertex merge:
consecutive vertices closer than ~1 pixel collapse (copy_view's
|dx|<=1 && |dy|<=1 screen-unit merge), polygons that collapse below 3
distinct verts return empty (retail's '<3 survivors -> count 0'). This
is the flood's PHYSICAL fixpoint floor - re-clipping can only insert
sub-pixel slivers, which the merge removes, so accumulated views
converge instead of drifting. Unit note: builder has no viewport, so
1 px is expressed as NDC at reference 1080p (0.00185); coarser at higher
res, which only strengthens convergence. This is the prerequisite for
removing the MaxReprocessPerCell=16 drift cap (T2 slice 2) and the
EyeInsidePortalOpening rescue.

Gates: all 10 flood conformance tests green (CornerSweep monotone pin
included); App 226 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:20:12 +02:00
Erik
579c8b06bc T1 (fused BR-2/3): retail frame order - dynamics last, punch+seal, shell chop deleted
The complete retail drawing order in one installment (per the amended plan:
every installment is a COMPLETE retail behavior - the half-ported punch of
88be519 is re-landed here WITH the ordering that makes it correct):

  static world (sky/terrain/weather/shells/scenery)
    -> aperture depth writes (interior SEAL at true depth / outdoor+look-in
       PUNCH to far-Z; PortalDepthMaskRenderer, DrawPortalPolyInternal
       Ghidra 0x0059bc90)
    -> interior cells WHOLE, far-to-near, drawn once (DrawCells Loop 2,
       Ghidra 0x005a4840; use_built_mesh pc:427905)
    -> per-cell STATIC object lists
    -> ALL dynamics LAST (DrawDynamicsLast), depth-tested, never hard-clipped

InteriorEntityPartition: new contract - every ServerGuid != 0 entity goes
to Dynamics regardless of cell (indoor/outdoor/unresolved/hidden); ByCell
carries only dat-baked indoor statics of visible cells; Outdoor renamed
OutdoorStatic. Fixes the audit's livedynamic-invisible-under-interior-roots
divergence as a side effect (live entities are never dropped by the
visibility set; culling is T3's viewcone).

DELETED (retail has no counterpart): the gl_ClipDistance shell chop
(927fd8f enable + 9ce335e outdoor scoping + UseShellClipRouting + the
per-slice shell loop + clipShells param) - retail never clips cell
geometry; aperture exactness = punch/seal + z-buffer + this order. The
old per-slice scissored AABB depth clear is replaced by retail's single
gated full clear (ClearDepthForInterior). The interior-root LiveDynamic
top-up draw and the look-in's dynamics involvement are gone (one last
pass, no double-draws).

Closes at the T5 gate (expected): #114 (chop deleted), the char-eaten-by-
doorway regression (ordering), outdoor interiors-through-doorways (punch);
#108's render half (seal) - its membership half stays re-attributed.

Suites: build green, App 226 green (partition tests rewritten to the T1
contract), Core 1398 + 4 pre-existing #99-era + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:16:27 +02:00
Erik
88be519ec0 revert(render): BR-2 depth discipline - the gate proved #108 is MEMBERSHIP, not depth
Visual gate (2026-06-11) on the seal+punch build, then on the punch-reverted
build, isolated the truth:
- With the punch wired: #108 (cellar grass-sweep) gone BUT the player/NPCs
  go transparent by exactly their overlap with any doorway viewed from
  outside (the far-Z punch erases the depth of dynamic objects standing in
  the aperture, so the interior paints over them).
- With ONLY the punch reverted (seal+full-clear kept): characters render
  correctly AND #108 is BACK.

The punch is wired for OUTDOOR roots + the look-in path ONLY; it never runs
on a clean interior (cellar) frame. For it to have suppressed #108, the
cellar-transition frames must render through the OUTDOOR root -> the player
is being classified OUTDOOR mid-cellar (the known #112/#106 cellar
membership ping-pong). So:
- #108 is a MEMBERSHIP bug (render is downstream of membership); the punch
  was MASKING it, harmfully. Re-attributed to the membership track.
- The interior-root SEAL addresses a case that is NOT #108 (confirmed: #108
  isn't an interior-root frame), so it has no verified visible effect yet.

Per no-workarounds + verify-before-layering: reverted ALL of BR-2's depth
machinery (seal, punch, the per-slice->full-clear swap) to the pre-BR-2
baseline (restored from 6cba950). The phantom-site probe (6cba950) is kept.
PortalDepthMaskRenderer.cs is KEPT as a RESERVED, unwired primitive (it is
verified-correct; the depth discipline will be rebuilt during BR-3 with
dynamics-after-interior ordering, where it can be verified against the
shell-chop deletion).

What survives from this session's execution: BR-1 (already-equivalent,
695eca2) stands. #108 moves to membership. BR-2 to be re-approached under
BR-3 with correct ordering. No net production behavior change vs 6cba950.

Suites: build green, App 226 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:35:57 +02:00
Erik
4ac547f6eb BR-2 commit 2: far-Z PUNCH on building entry apertures (outdoor + look-in)
Mirror of commit 1's seal: the punch half of retail's invisible portal
depth writes (DrawPortalPolyInternal maxZ1, Ghidra 0x0059bc90;
ConstructView(CBldPortal) mode-1, pc:433827).

Generalized DrawRetailPViewExitPortalSeal -> DrawRetailPViewPortalDepthWrite
with retail's maxZ1/maxZ2 selector (forceFarZ):
- INTERIOR root: forceFarZ=false (SEAL, true depth) - unchanged from c1.
- OUTDOOR root + DrawPortal look-in: forceFarZ=true (PUNCH, far depth) -
  erase the terrain depth inside a flooded building's entry aperture so
  the interior shows THROUGH the doorway against the nearer front-ground.

Architectural note (divergence from retail ORDER, same RESULT): retail
draws the shell LAST (DrawBuilding Draw(part,1) punch -> draw interior ->
Draw(part,0) shell) so the shell closes everything outside the punch. Our
pipeline draws the building shell FIRST (it is an outdoor WorldEntity in
the landscape pass), so the outside-the-aperture wall occlusion is already
in the depth buffer when interiors draw - we need ONLY the punch for
in-aperture visibility, no shell reorder. The punch is confined to each
door polygon clipped to its slice (NOT a full clear), so it does not
reintroduce the 'cellar paints over everything' hazard that gated the old
outdoor ClearDepthSlice to null.

DrawExitPortalMasks is now wired on the outdoor-root DrawInside context and
the DrawPortal look-in context (both previously null -> no-op).

Suites: build green, App 226 green, Core 1398 + 4 pre-existing #99-era.
NEEDS VISUAL GATE (batched with BR-3): outdoor interiors-through-doorways
must not bleed; cellar grass-sweep (#108) gone; tower stairs near+far.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:15:52 +02:00
Erik
6d4cac2418 BR-2 commit 1: exit-portal depth SEALS + retail full depth clear (the #108 machinery)
Ports the seal half of retail's invisible portal depth writes
(D3DPolyRender::DrawPortalPolyInternal, Ghidra 0x0059bc90; dispatched by
PView::DrawCells loop 1, Ghidra 0x005a4840 pc:432783-432786):

- NEW PortalDepthMaskRenderer: draws a portal polygon as a color-masked
  triangle fan, depth-test ALWAYS + depth-write ON, at the polygon's TRUE
  projected depth (retail maxZ2 seal) or forced to far-z 0.99999988
  (retail maxZ1 punch - the constant from 0x0059bc90's tail; punch wiring
  lands in BR-2 commit 2). Where retail software-clips the fan against
  the installed view (polyClipFinish), we apply the SAME slice region via
  gl_ClipDistance from the slice's <=8 clip-space half-planes. GL state
  fully self-contained (set -> draw -> restore, no early-outs).

- DrawExitPortalMasks is now WIRED in production (was a null-callback
  no-op since birth): for interior roots, every visible cell's portals
  with OtherCellId==0xFFFF get their world-space polygon sealed per view
  slice, far-to-near, after the landscape slices.

- ClearDepthSlice (per-slice scissored AABB clear - wrong shape, wrong
  scope, no seal after it) is REPLACED by ClearDepthForInterior: ONE
  full-buffer depth clear between the outside stage and the interior
  stage, gated on any outside slice having drawn (retail's
  portalsDrawnCount gate semantics staged as an open question, marked
  inline). DepthMask(true) asserted at the clear site (c4df241 lesson).
  Outdoor roots: no clear, no seals (interiors must depth-test against
  terrain until the commit-2 punch).

Closes the mechanism behind #108 (outdoor grass sweeping across the
upstairs door opening - terrain depth seen through the doorway is now
re-stamped at the door plane so farther interior geometry z-fails inside
the aperture). Visual gate: BR-2/BR-3 batched checklist (cellar doorway
+ cottage wall + tower stairs near/far).

Suites: build green, App 226 green, Core 1398 + 4 pre-existing #99-era
failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:03:10 +02:00
Erik
6cba95047c BR-2 task 1: phantom-site probe (ACDREAM_PROBE_PHANTOM)
The BR-1 pre-check left the #113 phantom residual with two surviving
suspects, both cell-side: (a) flood-admitted cells whose shell draws with
a pass-all slice (NoClipSlice fallback when the assembler handed no slot,
or an assembler slot-0 scissor-fallback slice), and (b) cell entity
buckets drawn unclipped + un-viewcone'd by design.

[phantom-shell]: per shell-pass cell, print-on-change - clip-enable
state, slot presence, every drawn slice's slot + plane count with
PASS-ALL flagged. [phantom-objs]: per object-list cell, print-on-change
- entity bucket size. Env-gated ACDREAM_PROBE_PHANTOM=1, zero cost off,
throwaway (strip when the phantom closes).

Repro protocol: launch with the probe on, stand at the hall bisect spot
(world ~216,-108 looking at the AAB3 meeting hall west face) where the
phantom is visible, read which mechanism fires for stair cells
0xAAB30100..0x106. Shells pass-all -> BR-2/BR-3 close it; statics ->
BR-5 closes it.

Build green; App suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:27:44 +02:00
Erik
124c6cb2af revert(render): #113 - un-apply the DrawingBSP poly filter (door regression); keep helper + dat pins
User gate 2026-06-11: the filter (e46d3d9) removed the phantom staircase
everywhere (verified) but DOORS disappeared across Holtburg - the naive
PosNode/NegNode walk evidently misses polygons some models reference
another way. Doors > phantom stairs: filter application removed; the
CollectDrawingBspPolygonIds helper and the dat-fact tests (hall orphans
0+1, cottage 0..7) stay as apparatus for the holistic building-render
port. First diagnostic for re-landing: run the DrawingBSP histogram on a
door GfxObj. See
docs/research/2026-06-11-building-render-holistic-port-handoff.md.

Branch state after this commit: outdoor-scoped shell clip (927fd8f +
9ce335e) + retail straddle gate (414c3de) + all diagnostics; phantom
staircase VISIBLE again (known, documented); doors functional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:47:44 +02:00
Erik
e46d3d9273 fix(render): #113 root cause #2 - GfxObj meshes draw only DrawingBSP-referenced polys (the REAL phantom staircase)
The user gate + bisect overturned the coincident-cell attribution: the
phantom staircase persists in the PRE-session build (bisect screenshot at
the hall wall) and is drawn by the ENTITY pipeline, untouched by any clip.

Root cause (dat-proven, DumpHallModel_PolyFlagHistogram): retail renders a
GfxObj by TRAVERSING its drawing BSP (D3DPolyRender); polygons present in
the Polygons dictionary but referenced by NO DrawingBSP node are never
drawn - they are physics/no-draw geometry. The Holtburg meeting hall
(0x010014C3) keeps its exterior stair-ramp as dictionary polys 0+1: in
the PhysicsBSP (ACE walks The Sentry on it at z 117-118; invisible-but-
walkable in retail) but orphaned from the draw tree (true at ALL degrade
levels - the LOD theory is dead, Degrades[0] IS the base model). The hill
cottage (0x01000827) carries 8 such orphans. Our extraction iterated the
dictionary -> drew the collision skeleton: the wall staircase up close,
the flying stairs over the cottage roofline from afar (orphan ramp spans
world 221-232 at z 116-124.5; visible over the cottage roof from the west).

Fix: PrepareGfxObjMeshData filters to CollectDrawingBspPolygonIds(gfxObj)
when a drawing BSP exists; models without one draw everything (unchanged).
Physics untouched (collision keeps the full physics set - retail parity).
CellStruct extraction not touched (different conventions; no orphan
evidence there yet).

Dat-backed pins: Issue113DrawingBspFilterTests (hall orphans == 0+1,
cottage orphans == 0..7). Suites: App 226 / Core 1392 + the 4
pre-existing #99-era failures / UI 420 / Net 294.

Note: the earlier shell-clip enable (927fd8f, scoped 9ce335e) remains
correct and orthogonal - it crops interior CELL geometry to apertures
outdoors; this commit removes the phantom SHELL geometry at its source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:52:52 +02:00