Commit graph

513 commits

Author SHA1 Message Date
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
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
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
66888d2c8e fix(textures): DecodeSolidColor null-safe against null ColorValue
A Base1Solid (or OrigTextureId==0) Surface can carry a null ColorValue;
DecodeSolidColor dereferenced it (color.Alpha) and threw NullReferenceException.
It is called directly from TextureCache.DecodeFromDats, OUTSIDE
DecodeRenderSurface's try/catch, so the NRE crashed the whole client. Surfaced
by the D.2b chrome prove-out feeding UI surface ids. Guard null -> Magenta
(the decoder's existing "undecodable" sentinel). Test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:36:07 +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
be03146e30 #112 ROOT CAUSE: outdoor-seed pick lacked retail's growing-array walk - threshold tick-skip became absorbing
The instrumented capture (cottage-112-capture1.log) + dat replay pinned
the transparent-cottage mechanism end to end:

1. The A9B3 cottage's entry cell 0x104 is a 0.22 m-wide THRESHOLD band
   (x 184.68->184.46 at y~82). A running player (~13-16 cm/tick at
   30 Hz) can cross it BETWEEN two physics ticks - the tick where the
   centre is inside 0x104 never happens.
2. Our outdoor-seed branch ran CheckBuildingTransit over a landcell
   snapshot and STOPPED - building-admitted entry cells were never
   expanded. The tick after the skip (centre in 0x100, a deep room not
   building-portal-adjacent) found no containing candidate -> the pick
   kept the outdoor landcell FOREVER (absorbing): the user walked the
   whole interior classified outdoor (render faithfully drew an outdoor
   frame = transparent walls), promoting only on touching
   portal-adjacent 0x102's own volume minutes later (captured:
   0xA9B3003C -> 0xA9B30102 with no transitions in between).
3. Retail cannot strand: CObjCell::find_cell_list (0x0052b4e0) runs ONE
   growing-array walk for EVERY seed (0052b576-0052b5ab,
   cells[i]->find_transit_cells vtable dispatch over the GROWING array)
   - the landcell's building bridge admits 0x104 (the foot sphere still
   overlaps the band one tick after the skip) and the walk expands
   0x104's portals to 0x100 where containment wins. Recovery fires one
   tick after any skip.

Fix: BuildCellSetAndPickContaining now runs retail's single growing
walk for both seeds with per-cell-type dispatch (landcells ->
CLandCell::find_transit_cells 0x00533800 -> CSortCell 0x00534060 ->
check_building_transit 0x0052c5d0; envcells -> FindTransitCellsSphere
with the straddle gate + once-per-walk outside add). The old indoor
branch behavior is preserved (seed at index 0, hysteresis, straddle-
gated outdoor pick); the outdoor branch gains the expansion + the
indoor branch gains the retail landcell bridge dispatch for
straddle-admitted landcells.

Pins (dat-backed, Issue112MembershipTests): tick-skip recovery one tick
past the threshold (RED pre-fix); run-speed entry replay across tick
phases never strands outdoor; threshold-gap outdoor-seed keeps outdoor
(over-fix guard); entry-walk replay diagnostic prints the full
promotion chain (0x3C -> 0x104 -> 0x100 -> 0x103 -> 0x100 -> 0x102).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:35:52 +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
1ca412d07b #119: entity bounds must cover the parts - the gaze-dependent staircase vanish
User re-gate after 2163308/987313a: run-from-town stairs FIXED, barrel
GONE - but the stairs still vanish by VIEWING ANGLE (visible climbing
down, gone climbing up; same at the tower top). The gate3 probe data
exonerates everything downstream: the entity always draws with correct
batches when it reaches the dispatcher (cache hit:119, restZ correct,
zero WALK-REJECTs, never clip-culled) - so the vanish lives in the one
gaze-dependent gate the probe cannot see: the bounds-based cullers.

WorldEntity.RefreshAabb was a fixed +-5 m box around the entity ANCHOR.
The staircase's 43 parts spiral 15 m ABOVE the anchor, and BOTH
visibility gates derive from the box: the dispatcher's per-entity
frustum cull AND RetailPViewRenderer.EntitySphere (the viewcone sphere
= this box's bounding sphere). Looking up the spiral put the anchor's
neighborhood out of view -> the whole entity culled while 15 m of it
stood in front of the camera; looking down kept the anchor in view ->
visible. Exactly the reported asymmetry.

Fix: expand the box by the largest MeshRef part-translation magnitude
(rotation-invariant, so entity.Rotation needs no handling; identity-
part entities get offset 0 - behavior unchanged; scenery scale is
already baked into the part transforms).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:58:17 +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
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
899145e1d7 #119-residual: tower-ascent harness pins the roof-lip flood gap; barrel claim RETRACTED (user axiom: not in retail)
User verdict on the post-#120 build: "Barrel is gone and more stairs
exist" - the #120 fix partially cured the tower, and the earlier
"legit dat barrels on the landings" claim is RETRACTED (USER AXIOM: the
barrel is NOT in the tower in retail; what the user saw was itself a
render artifact of the corrupted floods, and what the 0x020005D8 cell
statics actually render as is unverified - do not assume barrel).

Remaining tower bugs, both PINNED by TowerAscentReplayTests (the #118
exit-walk pattern, vertical - a helix ascent with the gaze locked ON
the staircase, so a cull has no gaze excuse):
- steps 195-201 (eye z 126.9-127.3, the roof-lip band between the main
  cell's ceiling at 126.8 and the roof aperture plane at ~127.2) resolve
  OUTDOOR and the per-building exterior flood admits NOTHING (flood=1 =
  the outdoor node alone): the eye is above every side aperture's useful
  view and ON/INSIDE the roof aperture's plane, so BuildFromExterior's
  seed side-test / in-plane reject refuses every exit portal. The tower
  interior never floods -> the staircase (a 0x0107 static) cone-culls
  while staying walkable (user symptom 1), and the roof-lip cell
  geometry flaps as the live eye bobs across the band's edges (user
  symptom 2). One mechanism, both symptoms.
- The pin is committed as a SKIPPED red test
  (TowerAscent_StaircaseStaysConeVisible_EveryStep; the skip reason
  carries the defect) so the suite stays green - un-skip with the fix.
- TowerAscent_RootDoesNotPingPong + the per-step diagnostic stay active.

Fix direction (oracle-first, next): determine which side diverges from
retail - (a) viewer-cell resolution (retail curr_cell may keep the eye
INTERIOR through the band: keep-curr above open-top cells / cell BSP
classifying the parapet bowl as inside 0x010A, where our resolution
demotes to outdoor), or (b) exterior seed admission (retail
ConstructView(CBldPortal) Sidedness with an in-plane eye). Grep the
named decomp for both before touching either layer.

Suites: App 238 + 1 skip (236+3 new, 1 pinned), Core 1419+2skip,
UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:34:45 +02:00
Erik
1b8c9f1f50 #119: tower decoded - the "missing stairs" are Setup 0x020003F2 (43-part spiral staircase); the "barrel" is a legit dat static orphaned by it
The user stood in the tower and logged out; the next login [snap] pinned
it: cell 0xAAB30107, AAB3 building[1] (model 0x01001117, NOT the #113
meeting hall 0x010014C3). Issue119TowerDumpTests decodes the dat truth:

- The tower interior cell 0x0107 has ZERO ramp polys - the stairs are
  NOT cell geometry. They are cell STATICS: Setup 0x020003F2 at the
  exact tower center = a 43-part spiral staircase (5 corner platforms
  0x01000E2A + 38 steps 0x01000E2B/2C/2D/2F/31/32, placement frames
  spiraling z 0.35..15.15, all parts fully drawable - 0 NoPos polys).
- The four 0x020005D8 statics (1 part, 0x01001774, 24 polys - barrel-
  shaped) sit along the wall at ascending heights: legitimate dat
  barrels on the stair landings. With the staircase missing, the
  bottom one reads as "a barrel in the middle where it's not supposed
  to be" - the barrel is NOT extraneous, the stairs around it are gone.

Pipeline reads (all correct by read, no errors logged):
BuildInteriorEntitiesForStreaming flattens Setup statics per part
(SetupMesh.Flatten -> 43 MeshRefs with placement transforms);
LandblockSpawnAdapter registers per MeshRef GfxObj id; the dispatcher
walks per MeshRef and composes PartTransform * entityWorld; the
ConsoleErrorLogger (wb-error) is wired and silent. Remaining suspects
are runtime-state: the #55 meshMissing population (parts never
finishing PrepareMeshDataAsync) or a draw-level drop - the saved
character now spawns INSIDE the tower, so a WB_DIAG launch at spawn
reads the dispatcher counters directly on the exact content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:59:52 +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
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
ca4b482f8b T6 (BR-7) C4: straddle-only outside-add (A6.P5 widening DELETED) + #90 stickiness removed
The two remaining flagged workarounds retired, per the BR-7 plan +
the WF1 [MEDIUM] correction (re-gate, do NOT delete the outside-add):

1. A6.P5 hasExitPortal topology widening DELETED. Outdoor cells enter the
   collision cell array ONLY on the retail straddle gate - |dist| <
   radius + F_EPSILON against an exterior portal plane
   (CEnvCell::find_transit_cells Ghidra 0x0052c820, gate 0052c9d6,
   live-binary verified) - the same flag that already gated the
   membership pick (#112 rider). The widening existed so outdoor-
   registered doors stayed findable from indoor cells under the old flat
   registry query; with per-cell shadow lists the door is found in the
   straddle-admitted outdoor cell's own list (tick-13558 pin holds).
   The hasExitPortal out-param + plumbing deleted from
   FindTransitCellsSphere; the AddAllOutsideCells call in
   BuildCellSetAndPickContaining re-gated on exitOutsideStraddle
   (once-per-walk = retail CELLARRAY.added_outside).

2. #90 ResolveCellId sphere-overlap stickiness REMOVED (the 4ca3596
   workaround, deferred-to-A6.P4 in the physics digest). It was dead
   code: the method's only caller is FindEnvCollisions' cache-null TEST
   fallback, and the indoor branch (where the stickiness lived) required
   a non-null DataCache. Production membership flows exclusively through
   the collide-then-pick advance whose ordered-array hysteresis (current
   cell at index 0, interior-wins-break) is the retail mechanism the
   workaround approximated. ResolveCellId reduced to the bare
   prefix-preserving outdoor re-derive, documented test-only.

Test updates (pins of the deleted behaviors inverted to retail):
- A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (asserted the
  topology widening verbatim) -> DeepInteriorSphere_NoStraddle_
  AddsNoOutdoorCells: a deep-interior sphere admits NO outdoor cells.
- A6P5_BuildCellSetFromAlcove... -> AlcoveSphere_StraddlesExitPortal_
  ReachesDoorOutdoorCell (the captured alcove position genuinely
  straddles - the retail-positive half).
- Issue112MembershipTests straddle pin + the second-sphere straddle test
  updated to the single-flag signature.

Suites: Core 1416/0/2, App 225, UI 420, Net 294 - green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:44:49 +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
abf36e2743 T6 (BR-7) C2: BuildShadowCellSet - the registration-side portal flood
Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, pc:308742)
as invoked by calc_cross_cells / calc_cross_cells_static (0x00515230 /
0x00515160) - the flood retail runs at SHADOW REGISTRATION time, minus
the containing-cell pick (registration passes a null out-cell):

- Seed: indoor -> exactly that one cell (added even when unloaded, like
  retail's null-pointer add_cell; the walk is then skipped per the
  0052b576 seed-pointer gate); outdoor -> block-crossing
  AddAllOutsideCells.
- Growing-array walk: indoor cells via FindTransitCellsSphere
  (sphere-vs-neighbor-BSP admission; exterior straddle -> outside cells
  once per walk, retail CELLARRAY.added_outside); outdoor cells via the
  CLandCell leg (0x00533800) = add_all_outside_cells (same once-guard)
  + the building bridge CSortCell -> CBuildingObj ->
  check_building_transit (0x00534060/0x006b5230/0x0052c5d0) - how an
  outdoor-positioned door reaches the vestibule's shadow list at
  registration. No XY grid, no visibility lists (the spec's
  VisibleCellIds rule was REFUTED by the WF1 verification).
- Static prune (do_not_load_cells, 0052b66e): indoor-seeded statics keep
  only {seed} + seed.stab_list (VisibleCellIds) - also strips outdoor
  cells, matching retail (interior statics never shadow into landcells;
  outdoor spheres reach them through their own array's building bridge).

11 unit tests: seeds (indoor/outdoor/unloaded), neighbor-BSP admission,
building bridge incl. the C1 negative-portal-id gate, exterior straddle
on/off, static prune drop/keep, zero-sphere no-op.

Consumed by C3 (ShadowObjectRegistry rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:02:08 +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
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
695eca2c1f BR-1: RESOLVED as already-equivalent - premise falsified by pre-check, equivalence pinned, #113 attribution corrected
The plan's BR-1 ('implement the skipNoTexture draw-time surface gate')
died on its pre-check: acdream ALREADY suppresses every portal fill.
ReplicateProductionEmission_OnPortalFills replicates the exact emission
conditions of the production extractors on the hall/cottage fills:
pos=False neg=False for every one (Stippling.NoPos skips the positive
side at ObjectMeshManager.PrepareGfxObjMeshData:1046,
PrepareCellStructMeshData:1394, CellMesh.Build:44, GfxObjMesh.Build:71;
the fills have no negative surface). There is nothing to gate.

What ships instead: StipplingSurfaceEquivalenceTests - 2,607 polys across
13 building models + 13 environments, ZERO violations both directions:
NoPos <=> untextured-surface. Our build-time skip is proven equivalent to
retail's draw-time skipNoTexture rule (Ghidra 0x0059d4a4, default on
@0x00820e30) on this content. The pin fails loudly if future content
breaks the invariant - the cue to implement the draw-time gate then.

Corrections folded into the plan + comparison docs:
- The #113 phantom residual CANNOT be GfxObj fills (they never reach a
  vertex buffer). Plausible true sites are cell-side: flood-admitted
  cells drawn with the pass-all NoClipSlice when slot-less
  (RetailPViewRenderer.cs:71), and/or cell statics drawn unclipped +
  un-viewcone'd (object-lists-skip-portal-view-gate, confirmed).
  BR-2 opens with the probe that pins which.
- The e46d3d9 user-gate observations (filter removed phantom/doors) were
  confounded - the filter was a provable mesh no-op on shells AND doors.
- Ledger rows solid-surface-skip-missing + the acdream half of
  portal-polys-baked-unconditional re-marked REFUTED-for-fills; the
  retail mechanism descriptions and the un-consumed PortalIndex->
  CBldPortal pairing (BR-4) stand.

Suites: Core 1398 green (1392 baseline + 6 new facts) + the 4 pre-existing
#99-era failures + 1 skip. No production code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:25:31 +02:00
Erik
31ea849277 test(conformance): skipNoTexture confirmation - ALL Holtburg portal-fill quads are Base1Solid (untextured)
Phase A confirmation fact (DumpPortalFillSurfaceTypes): every portal-fill
polygon on the audited building models (hall 0x010014C3, cottages
0x01000827/0x0100082E/0x01000C17) carries an untextured surface
(Base1Solid, mostly +Translucent) with Stippling=NoPos and no negative
surface. Retail's skipNoTexture rule (D3DPolyRender inner draw 0x0059d4a0,
default on @0x00820e30) therefore skips ALL of them on the building/cell
pass - door fills, window fills, AND the phantom stair-ramp. Retail never
draws any baked fill; visible doors are door ENTITIES. acdream draws the
solid batches as colored geometry, which is both the phantom staircase AND
why dropping them read as 'doors disappeared'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:54:12 +02:00
Erik
e223325410 test(conformance): door-vanish mystery SOLVED - every 'orphan' is a DrawingBSPNode.Portals PortalRef (no static filter can be right)
Charter open mystery #1 (docs/research/2026-06-11-building-render-holistic-
port-handoff.md "4.1): the e46d3d9 DrawingBSP poly filter made doors vanish
because the PosNode/NegNode walk only collected node.Polygons and never
node.Portals (List<PortalRef> {PolyId, PortalIndex}).

Dat-proven across all 13 Holtburg-area building models (A9B4/A9B3/AAB3/
A9B5/AAB4): TRUE-orphans = ZERO everywhere. Every dictionary poly the
filter dropped is a PORTAL POLYGON - the baked door-filling (1.9x2.5 m)
and window-filling quads at doorway/window apertures, AND the meeting
hall's phantom stair polys {0,1} (ramp-shaped portal apertures into the
interior stair cells).

Consequences for the holistic port:
- The door entities (setup 0x020019FF) were never affected: base parts +
  every degrade variant have full BSP coverage, and doors don't take the
  IsIssue47HumanoidSetup degrade swap. The vanished 'doors' were shell
  portal polys.
- Retail draws portal polys CONDITIONALLY during portal-view traversal
  (closed doors/windows draw a surface; open apertures and the hall's
  stair apertures don't). The phantom staircase and the door rendering
  are the SAME mechanism with opposite signs - there is NO correct
  static filter; this is the dat-side proof the one-drawing-discipline
  port is required.
- The exact retail conditional (BSP portal-node draw gate in
  CPhysicsPart::Draw / BSPPORTAL) is a named Phase A question.

Diagnostic-only commit: new dump facts in
Issue113DoorVanishDiagnosticTests (door setup + degrade chains, control
models, Holtburg orphan sweep with portal discrimination). No production
code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:10:20 +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
Erik
414c3deaf4 fix(phys): #112 residual - retail straddle gate for outdoor-cell admission (live-binary verified)
The oracle read the #112 residual was waiting on, settled against the
LIVE 2013 client (cdb attach, CEnvCell::find_transit_cells @ 0052c820;
BN pseudo-C was ambiguous and partly wrong per
feedback_bn_decomp_field_names - it invented portal_side tests in this
branch): retail admits outdoor transit cells from an indoor cell IFF a
path sphere STRADDLES an exterior portal polygon plane,
|dist| < radius + F_EPSILON(0.000199999995, @ 007c8c70). The flag at
[esp+18h] (set 0052c925, x87 decode fcompp/test ah,41h +
fcomp/test ah,5/jp) gates the add_all_outside_cells call (0052c9d6 je).
Graph reachability alone NEVER admits outdoor cells in retail.

Port (CellTransit):
- FindTransitCellsSphere: exitOutside now carries the retail straddle
  semantics; new hasExitPortal out carries the old topology-only flag.
- BuildCellSetAndPickContaining: the collision cell SET keeps the A6.P5
  topology widening on hasExitPortal (outdoor-registered doors must stay
  findable from indoor cells until #99/A6.P4 ships per-cell shadow
  lists - the 2026-05-25 door capture scenario), but the membership
  PICK's outdoor branch is gated on the retail flag. Membership is now
  retail-identical in both regimes: straddle -> outdoor candidates valid;
  no straddle -> outdoor ignored -> retail keep-curr. This is what stops
  deep-interior containment gaps in ANY house from demoting to outdoor
  (the #112 transparent-interior shape) - the systemic protection the
  user asked for, without house-by-house verification.

The at-doorway A9B3 gap demote is RETAIL-FAITHFUL (gap point is 0.23m
from 0x104s door plane < 0.48 foot radius -> retail straddles + demotes
+ self-heals inward): DocumentsResidual renamed to
...DemotesRetailFaithfully, expectation unchanged. New conformance pins:
deep-gap keep-curr (A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell)
+ function-level gate semantics on real dat geometry
(FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail).

Tests: Core 1391 green (+2) / App 224 / UI 420 / Net 294; pre-existing
4 #99-era failures unchanged; P1 membership goldens + A6.P5 door-set
tests explicitly green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:52:24 +02:00
Erik
927fd8fde2 fix(render): #113 - enable GL clip distances for the PView shell pass (phantom exterior staircase)
Attribution (dat-evidenced, supersedes the misplaced-cell hypothesis):
the phantom staircase is the Holtburg MEETING HALL (AAB3 building[0],
model 0x010014C3 at AAB3-local (36,84,116)), NOT an A9B3 building - the
user stood at the A9B3/AAB3 boundary (cell-transit trail in
issue112-gate1.log) and clicked through the hall to the NPC behind it.
The hall's interior stair cells (0x100..0x106, ring climbing z 116->124.5
to the deck hatch) have geometry coincident with the shell's west wall
(both at local x=29.0). Our outdoor per-building flood admits them with
CORRECT tight clip regions (4-6 planes, door-aperture NDC boxes -
Issue113MeetingHallFloodTests proves it), but DrawEnvCellShells drew them
WHOLE: mesh_modern.vert writes gl_ClipDistance from the routed CellClip
slot, and gl_ClipDistance is ignored unless GL_CLIP_DISTANCEi is enabled -
which no caller ever did for the shell pass (born inert in 1405dd8).
Interior staircase painted across the exterior wall; unpickable because
it is cell geometry, not an entity.

Retail oracle: cell geometry IS clipped to the accumulated portal view -
Render::set_view (:343750) installs the view polygon edge planes,
DrawEnvCell submits every cell polygon with planeMask=0xffffffff (:427922)
through ACRender::polyClipFinish. Characters/meshes are NOT poly-clipped
(viewconeCheck path) - entity routing stays cleared, comment scoped.

Fix: enable GL_CLIP_DISTANCE0..7 around exactly the shell pass
(self-contained per feedback_render_self_contained_gl_state; no early-outs
between set and restore). Slot-0 fallback slices (>8-plane regions) still
draw pass-all - the assembler's scissor fallback remains unimplemented and
documented; the new flood test pins 0 such slices at the hall.

Refuted along the way (full evidence in Issue113PhantomStairsDumpTests):
- ONE misplaced interior EnvCell unifying #113+#112+collision gaps: all 17
  A9B3 cottage cells share an identical dat Position (nothing to misplace);
  the #112 gap is a real 20cm doorway micro-gap 0.23m outside threshold
  cell 0x104 (straddles its exterior portal plane at foot radius 0.48);
  missing object collision remains #99/A6.P4.
- A9B3 dat content near the spot: no stair geometry in shell (balcony at
  z119 + turret roof only), cells (flat 116/118.8), statics, or stabs.

Tests: Core 1389 green (+6 dump facts) / App 224 (+1 flood replay) /
UI 420 / Net 294; pre-existing 4 #99-era failures unchanged.
Visual gate pending: user re-check of the hall west face vs retail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:26:55 +02:00
Erik
2d6954ee44 fix(phys): #112 - remove the non-retail escape-hatch demote from the pick; lateral stab-graph recovery + retail keep-curr
Root cause (oracle: CLandCell::point_in_cell :316941 = terrain-poly only;
find_cell_list null-result keep-curr pc:308788-308825; CEnvCell::
check_building_transit :309827 = sphere_intersects_cell per portal-adjacent
cell): retail KEEPS curr_cell when nothing contains the centre — including
inside a house's containment gaps. Our 6dbbf95 escape hatch instead demoted
any hydrated indoor claim the sphere no longer overlaps to the outdoor
column; at the A9B3 hill cottage's real interior gap this stranded the
player outdoor-classified deep indoors, where re-promotion is portal-
adjacent-only (retail-identical) -> the outdoor flood rendered the interior
transparent (the user's "sometimes transparent" walk).

The hatch's actual target - poisoned (cell, position) SAVES - has been
handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition validation
since #107/#111, so the per-tick pick reverts to retail semantics:
1. lateral recovery first - when the sphere no longer overlaps the claim,
   search the claim's stab list for a containing cell (retail
   find_visible_child_cell :311444, the same recovery AdjustPosition uses);
   the #111 adjacent-claim shape now self-heals laterally (dat-backed test:
   pick(seed 0x172) at a 0x171-interior point -> 0x171);
2. else KEEP curr_cell (retail null-result).

Two old tests asserting the hatch demote rewritten to the retail semantics
(tests-can-codify-bugs); P1 retail-golden conformance gates explicitly green
(FindCellListConformance + ThresholdPortalCrossing + CottageDoorway +
CameraCornerSeal = 11/11). New Issue112MembershipTests: the lateral-recovery
fact + a DocumentsResidual fact pinning the remaining at-doorway gap demote
(via the NORMAL outdoor-candidate path; open oracle read = retail's
add_all_outside_cells gate in CEnvCell::find_transit_cells pc:317499 -
sphere-proximity vs graph-reachability). Core 1383 + 4 pre-existing #99
failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:03:49 +02:00
Erik
e4f6750e09 fix(phys): #107 gate-run regression — auto-entry hold opened on a mid-population cache read; wait for the claimed cell itself
Gate-run finding (wedge-107-gate-indoor-login.log + dat scan): ACE saved the
cell one room off (claim 0xA9B40172, position inside 0xA9B40171 — adjacent
rooms). FindVisibleChildCell corrects this fine when hydrated (proven by the
new AdjacentRoomClaim regression test), but the live entry committed the raw
claim: IsSpawnCellReady's "any cell struct in the landblock => claim is bogus,
proceed" disambiguator observed the MID-POPULATION state (interiors hydrate in
id order on the background worker; the render-thread predicate read the cache
mid-loop) and opened the gate before the claim and its stab neighbors were
cached. AdjustPosition then saw a null cell struct and silently passed the
claim through; the first movement demoted the player to outdoor inside the
house — the user-visible "transparent interior, see straight through walls"
(render is downstream of membership: an outdoor-classified viewer only sees
the interior through the doorway flood).

Fix: the hold now waits for THE CLAIMED CELL's struct, full stop
(IsSpawnCellReady simplification; HasAnyCellStructInLandblock removed).
Claims that can never hydrate are filtered by GameWindow against the dat's
LandBlockInfo.NumCells range (memoized IsSpawnClaimUnhydratable), and
PhysicsEngine.Resolve carries a loud lost-cell-equivalent safety net: an
indoor claim with NO cell struct AND NO CellSurface floor data demotes to the
outdoor landcell with a [spawn-adjust] line instead of committing raw
(retail GotoLostCell :283418; documented divergence). Partial hydration
(CellSurface present, struct pending) keeps the legacy floor-snap behavior —
HasCellSurface uses the file's masked-low-word norm so bare-id fixtures and
full-id production both resolve.

Baseline restored: Core 1381 (+4 new #107 conformance tests) + 4 pre-existing
#99-era failures + 1 skip; App 223 / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:10:01 +02:00
Erik
1090189d39 fix(phys): #107 indoor-login spawn wedge — validate the server (cell,pos) pair at the player snap (retail AdjustPosition) + unfreeze same-landblock teleport arrivals + self-consistent wire pairs
Root cause (capture resolve-107-login1.jsonl + dat conformance scan): ACE
restored the player with a POISONED (cell, position) pair — cell 0xA9B40162
(one building) with a position inside 0xA9B40171 (a different building 55 m
away). Our entry snap trusted the claim verbatim: the player stood
fake-grounded for minutes (isOnGround passthrough, no contact plane, no
walkable polygon — zero-move resolves short-circuit), the FIRST movement input
ran a real transition, the pick demoted the indoor claim to outdoor
mid-building, and the player fell 2.4 m through the cottage floor onto the
terrain underneath — wedged inside the building shell. The second wedge shape
(flood-fix-gate2.log) was the PortalSpace freeze: the teleport-arrival
detection gated on `differentLandblock || farAway>100m`, an invented
heuristic — ACE's same-landblock short-hop corrections matched neither, so
PortalSpace never exited and movement input stayed frozen all session.

Four legs, all retail-anchored:

1. PhysicsEngine.Resolve (the player snap path: login entry + teleport
   arrival) now runs AdjustPosition first — retail SetPositionInternal step 1
   (acclient :283892, AdjustPosition :280009): validate/correct the claimed
   cell from the foot-sphere center BEFORE any physics. Corrections log one
   [spawn-adjust] line.
2. AdjustPosition's previously-deferred indoor seen_outside →
   adjust_to_outside sub-fallback (:280037-280046) is completed; CellPhysics
   gains the SeenOutside flag (dat EnvCellFlags.SeenOutside) cached in
   CacheCellStruct. The camera path does not reach this sub-branch in the
   gated scenarios (CameraCornerSealReplayTests green).
3. PortalSpace arrival = ANY player position update (holtburger PlayerTeleport
   handler conformant; recenter still only on landblock change). Verified
   live: ACE sent a same-lb dist=69.8 correction that the old gate would have
   frozen on — it now completes.
4. Outbound wire (cell, position) pairs are now SELF-CONSISTENT: derive the
   landblock frame from the resolver's full cell id instead of welding a
   position-derived landblock onto its low word — the old composition could
   write exactly the poisoned pair shape into ACE's character save. Plus the
   #106-gate-2 hold extension: an indoor spawn claim waits for the claimed
   cell's hydration (IsSpawnCellReady) so the validation can act — the async
   equivalent of retail's synchronous cell load.

Live verification (wedge-107-verify1.log): entry clean; ACE's same-lb teleport
correction completed (old code: permanent freeze); the teleport destination
itself carried ANOTHER poisoned claim (0xA9B40150) which [spawn-adjust]
corrected to 0xA9B40019; player fully controllable, walking across landcells.
3 new dat-backed conformance tests pin the poisoned-pair facts
(Issue107SpawnDiagnosticTests). Baseline: 1380+4 pre-existing #99-era
failures+1 skip / 223 / 420 / 294.

Pending user gate: park indoors, log out gracefully, relaunch — expect a clean
indoor spawn standing on the interior floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:52:47 +02:00
Erik
dac8f6ad1f fix(render): §4 flood strobe — homogeneous reciprocal clip + collinear-aware region dedup
THE BUG (pinned deterministically by the new CornerFloodReplayTests harness — real
Holtburg cells, captured corner-press scenario): a smooth 2 cm/step monotonic eye
sweep across the 0172↔0173↔0171 doorway produced a NON-monotonic flood — on ~10 of
61 steps the player's room (0172) vanished from the flood entirely or collapsed to
a sub-pixel sliver, taking its downstream chain (016F, the outside view) with it.
Live, those isolated frames are the §4 background strobe: openings/passages flash
the clear color during transitions, and the corner press shows background at the
angles that park the eye near the doorway plane.

TWO root causes, both fixed:

1. ApplyReciprocalClip ran the reciprocal portal polygon through the legacy
   divide-first ProjectToNdc + 2D Intersect path, justified by "the reciprocal is
   never near the eye." That assumption is exactly false at doorways/corners: the
   reciprocal IS the same opening whose plane the eye presses against (2-60 cm).
   ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
   there — 2 cm eye moves flipped its output between a no-op and a duplicated-
   vertex hairline that ground the healthy region down to <3 distinct vertices.
   FIX: route the reciprocal through the SAME homogeneous pipeline as the forward
   clip (ProjectToClip + ClipToRegion) — which is what retail does:
   PView::OtherPortalClip (decomp:433524-433563) runs the reciprocal through the
   very same GetClip(finish=1) → ACRender::polyClipFinish homogeneous clipper.
   Also ported retail's skip: exact_match portals (CCellPortal.exact_match,
   acclient.h:32300; PView::ClipPortals :433689) bypass the reciprocal clip —
   both sides share the same polygon, so re-clipping is redundant.

2. CellView.CanonicalKey missed COLLINEAR re-emissions: the homogeneous region
   clipper legitimately inserts intersection vertices ON a subject edge when a
   region edge grazes it, so BFS re-clip rounds re-emit the SAME geometric region
   with 1-2 extra collinear edge vertices — keyed as distinct, defeating the
   dedup and accumulating duplicate polygons (this was the real mechanism behind
   the historical "float drift defeats the dedup" rationale that had parked the
   reciprocal on the unstable path). FIX: canonicalize away collinear snapped
   points (exact integer cross-products on the 1e-3 NDC grid) so the key is
   purely a function of the region's corners.

Conformance: CornerSweep_FloodIsCompleteAndMonotone pins the fixed behavior —
61-step monotonic eye sweep ⇒ full flood every step, outside view always reached,
player-room region monotone (was: clean shrink 4.000→2.879 with zero drops, vs
~10 glitch steps before). Diagnostic facts (trace diff, hop microscope, primitive
scratch) retained as the apparatus.

Suites: App 223 green (incl. Build_AppliesReciprocalOtherPortalClip, now passing
with proper tightening AND dedup), Core 1377 green + the 4 pre-existing #99-era
failures + 1 skip, UI 420, Net 294. Visual gate pending: corner press, room↔room,
cellar↔floor, indoor↔outdoor transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:26:01 +02:00
Erik
b21bb28918 test(phys): §2b corner-seal replay — camera-penetration hypothesis REFUTED (openings, not walls)
Replays the captured corner/exit "escape" sweeps (corner-seal-capture.log) through
the real ResolveWithTransition with the camera probe call shape. Geometry-map
diagnostic proves every zero-contact traversal runs through a REAL opening: the
0170 exit-door portal for the viewer-outdoor frames; the 0171↔0173↔0172 doorway
chain (0173 = 20 cm threshold cell) for the corner-press frames. Captured eyes land
inside door-opening rectangles; the containment walk shows no path point in solid
cell volume; 8,703/14,230 indoor sweeps in the same capture collided correctly
(pull-in up to 2.77 m) — camera collision works, nothing penetrates.

The user-visible "background at the corner" is therefore NOT collision — it is the
§2a edge-on portal-clip collapse with the eye hovering at the doorway plane. Both
§4 siblings converge on one render-side mechanism: edge-on clip collapse near
opening planes. Next per the 2026-06-08 handoff §5.3: read retail's edge-on clip
oracle (PView::GetClip :432344, PView::ClipPortals :433572, polyClipFinish :702749)
before designing the fix.

Assertion fact = characterization pinning the verified behavior (openings pass
clean); Diagnostic fact = dispatch trace + room map + containment apparatus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:59:48 +02:00
Erik
6dbbf953c6 fix(phys): #106 gate-2 — bogus-indoor-claim recovery + spawn-ground entry hold
Gate-2 fallout chain: session 1's bare-id wedge poisoned ACE's save (the
client reported garbage cells while walking through walls), so session 2
logged in with (cell=0xA9B4013F inn interior, pos=(91.4,32.7)) — a
position that cell does not contain. Probe evidence: exactly one
[cell-transit] line all session; the player free-fell into an empty
world. Two holes, both fixed at the root:

1. CellTransit pick escape hatch — restores the #83/A1.7 + #90
   verification that lived in PhysicsEngine.ResolveCellId before the
   collide-then-pick rewrite moved membership into
   BuildCellSetAndPickContaining: an indoor current cell that IS
   hydrated but whose CellBSP no longer overlaps ANY part of the foot
   sphere is a bogus claim (corrupt save, or walked out through an
   unblocked gap). The portal BFS can never reach an exit portal from a
   cell the sphere isn't in, so no candidates exist and the claim held
   forever — wedging collision (ShadowObjectRegistry's #98 gate reads
   "indoor primary" -> outdoor object sweep skipped), wall BSP, terrain,
   and the render root. The pick now demotes to the outdoor column under
   the sphere centre (the LandDefs.AdjustToOutside result already
   computed for the pick — cross-block safe). Sphere-overlap
   (BSPQuery.SphereIntersectsCellBsp, pseudo_c:317666 -> :323267), NOT
   point-in: doorway push-back leaves the centre a few cm outside while
   the sphere still overlaps — no demotion, #90's ping-pong stays dead.
   An unhydrated cell cannot be verified — stale beats null while
   streaming hydrates (retail-equivalent: stale curr_cell kept when the
   pick finds nothing).

2. PlayerModeAutoEntry spawn-ground hold — player-mode entry now waits
   for the terrain under the spawn position to stream in
   (isSpawnGroundReady predicate, K.2 pattern). Entering earlier
   integrates gravity against an empty world: indoor-claimed spawns got
   no floor from any source and free-fell into the void; outdoor spawns
   raced hydration by ~1s every login. Retail never has this state (it
   loads cells synchronously) — the hold is the async-streaming
   equivalent of that invariant. With the hold, the entry snap
   (Resolve, stepUp=100) runs against hydrated cell floors + terrain
   and re-seats a corrupt save's claim immediately.

Tests: IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn
(the gate-2 wedge shape, red pre-fix), straddle + no-BSP guards (the #90
hysteresis and stale-beats-null), TryEnter_Armed_SpawnGroundNotReady_
DoesNotFire. Full suite: 294+218+420 green; Core 1375 green + the same
4 pre-existing door/#99-era failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:47:11 +02:00
Erik
23adc9c9df fix(phys): #106 follow-up — legacy Resolve returns full prefixed cell ids (teleport bare-id wedge)
The #106 live gate run was sabotaged by a pre-existing bug the corrective
ACE teleport exposed: PhysicsEngine.Resolve (the legacy Phase-D resolve,
still used by the teleport-arrival snap at GameWindow.cs:4869 and the
player-mode-entry snap at :11295) returned BARE low-word cell ids on
every computed exit (ComputeOutdoorCellId, bestCell.CellId & 0xFFFF,
nextCellIndex, enterCellIndex). The teleport committed 0x0000013F into
PlayerMovementController.CellId, and a bare indoor id wedges the entire
membership chain:

- GetCellStruct(0x0000013F) misses (cells are keyed full-prefix) -> no
  indoor wall BSP -> walk through walls;
- the b3ce505 #98 gate reads "indoor primary" -> outdoor object radial
  sweep skipped -> NO object collision anywhere in the world;
- BuildCellSetAndPickContaining early-returns an unresolvable id forever
  (block 0x0000 is a real far-NW map block) -> membership frozen;
- render root never resolves -> interiors draw empty when stepping in.

Probe evidence: probe-cell-106-gate.log has exactly 2 [cell-transit]
lines for the whole session, both reason=teleport — the second one
(0xA9B3003C -> 0x0000013F) is the wedge. This is the L.2e "player CellId
tracked as bare low byte" finding (2026-05-12) finally biting; prefix
survival until now was a race artifact — Resolve only preserved the full
id when the landblock had not streamed in yet (passthrough exit), which
is why login snaps usually came out prefixed.

Fix: capture the matched landblock's key in Resolve's containment loop
and return lbPrefix | (targetCellId & 0xFFFF) on the computed exit —
the same full-32-bit convention Resolve's own doc comment states for
its inputs, and what both production callers (player snaps) require.
The passthrough exits (no landblock / step-reject) still return the
caller's id unchanged.

Tests: Resolve_IndoorStay_ReturnsFullPrefixedCellId (the teleport
shape, red pre-fix) + Resolve_OutdoorStay_ReturnsFullPrefixedCellId;
Resolve_LeaveIndoorCell_TransitionsToOutdoor's unmasked
`CellId < 0x100` assertion codified the bare behavior — now masked +
asserts the prefix. Full suite: 294+218+420 green; Core 1371 green +
the same 4 pre-existing door/#99-era failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:34:30 +02:00
Erik
7078264291 fix(phys): #106 — outdoor membership crosses landblock boundaries (LandDefs global-lcoord port)
The player's outdoor cell froze at the last in-block cell the moment they
walked over a landblock boundary (10,449-frame playerCell freeze in the
2026-06-09 capture; whole neighbouring-block interiors unenterable, plus
the running-distortion from the stale render anchor). Root cause: the
add_all_outside_cells port clamped BOTH the candidate proposal and the
find_cell_list containing-cell pick to the current landblock's 8x8 grid,
in a frame that silently assumed the current block sits at world origin.
One step over the line -> zero candidates -> FindCellSet returns
currentCellId forever.

Retail has no such clamp. Its cell math runs in a GLOBAL landcell grid
(lcoord 0..2039 spanning the map): get_outside_lcoord = blockid_to_lcoord
+ floor(blockLocalPos/24) with no bounds besides the map edge, and
lcoord_to_gid re-derives the landblock id from the lcoord's upper bits —
crossings are inherent, never special-cased.

The fix, decomp-cited throughout:
- New AcDream.Core.Physics.LandDefs: in_bounds (pc:68509),
  blockid_to_lcoord (pc:68520), inbound_valid_cellid (pc:163438),
  gid_to_lcoord (pc:163500), lcoord_to_gid (pc:171859),
  get_outside_lcoord (pc:438690), adjust_to_outside (pc:438719).
  Cross-checked against ACE LandDefs.cs; three artifacts documented and
  avoided: BN's int8_t mis-render of block_y, BN's dropped 192f
  BlockLength constant, and ACE add_cell_block's "FIXME!" same-block
  guard (an ACE divergence, not retail).
- CellTransit.AddAllOutsideCells rewritten as the faithful sphere
  variant (pc:317499 @0x00533630): adjust_to_outside re-seats the
  (cell, position) pair cross-block, check_add_cell_boundary (pc:317229)
  adds up to 3 neighbours by global lcoord, add_outside_cell (pc:317056)
  has no same-block filter. adjust_to_outside failure breaks the sphere
  loop (pc:533699 verbatim).
- BuildCellSetAndPickContaining: the outdoor containing-cell pick is now
  the global XY-column under the sphere centre (AdjustToOutside), not
  the [0,8)-clamped current-prefix reconstruction. Interior-wins order
  and current-cell-first hysteresis unchanged.
- World->block-local frame conversion via the landblock origin already
  registered in CellGraph (new TryGetTerrainOrigin); Zero fallback
  preserves the legacy anchor-block assumption for unregistered terrain.
- Cross-landblock building entry comes free: the candidate snapshot now
  contains neighbour-block landcells, so GetBuilding/CheckBuildingTransit
  fire for cottages across the line (the capture's one failing entry).

Investigated FIRST per the pickup brief: the b3ce505 #98 stopgap gate is
definitively exonerated — it is a collision-object query gate that fires
only for indoor primary cells; no membership path touches
ShadowObjectRegistry.

Tests: 31 new (25 LandDefs conformance incl. capture-geometry goldens
0xA9B40031 -> 0xA9B30038/0xA9B30034 and the northbound return; 4
AddAllOutsideCells cross-block; 3 FindCellSet membership goldens incl.
the non-anchor-frame origin conversion). Full suite: 294+218+420 green;
Core 1369 green + the 4 pre-existing door/#99-era failures + 1 skip
(unchanged from baseline).

Pseudocode + artifact notes:
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.
Remaining acceptance: live boundary walk with ACDREAM_PROBE_CELL=1
(ISSUES.md #106).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:10:59 +02:00
Erik
b3920d83f6 test(conformance): dat-reader concurrency hammer — concurrent READS exonerated
Settles the long-standing lore that DatCollection is not thread-safe for reads,
for Chorizite.DatReaderWriter 2.1.7: replays acdream''s real four-population
access pattern (render / streamer / decode-pool / raw) against the live dats —
golden FNV-1a fingerprints taken single-threaded, then 8 threads x 25 shuffled
passes over ~2900 files spanning the cell heightmap/LBI/EnvCell set and the
portal texture chain (Environment -> Surface -> SurfaceTexture -> RenderSurface
incl. highres probes). Two layers: raw TryGetFileBytes (BTree + ReadBlock, no
caching) and typed TryGet with FileCachingStrategy.Never (full production
unpack path: ArrayPool + DatBinReader + ObjectFactory).

Result: ~1.1M concurrent reads, ZERO anomalies — byte-identical to golden.
Matches the line-level audit of the release/2.1.7 source (ReadBlock keeps all
cursor state in locals over a stable read-only mmap view; locked LRU BTree
node cache; ConcurrentDictionary file cache; fresh DatBinReader per call).
The real crash bug was dispose-during-read at teardown (fixed in 8fadf77).
Keep this as the regression guard for any future dat-reader version bump.

Skips cleanly when the dats are absent (CI), matching suite convention.
Full evidence: docs/research/2026-06-09-dat-reader-thread-safety-investigation.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:28:32 +02:00
Erik
485e44d163 fix(render): R-A2b — cull back portal like retail (InitCell side test), kill the indoor flap cycle
Pinned (flap-sidechk.log): the indoor doorway flap is a 0171<->0173 flood cycle. Back portals show camInterior=False (our side test already agrees with retail) but were traversed when eyeIn=True because the side-cull had an  bypass (added 2026-06-05 for the void). Within 1.75m of a doorway that bypass kept the BACK portal alive -> mutual re-contribution -> re-enqueue churn (maxPop=16) -> eye-sensitive flood depth -> grey flap + dropped floor.

Fix (Option B1): drop the bypass from the side-cull in Build + BuildFromExterior so back portals cull by the side test alone, exactly like retail PView::InitCell (:432962, no eye-in-opening bypass). The forward-portal clip-empty void rescue is a SEPARATE branch and is untouched (Build_EyeStandingInInteriorPortal_FloodsNeighbour stays green). New RED->GREEN test Build_BackFacingPortal_EyeStandingInOpening_StillCulled; full App suite 218 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:06:57 +02:00
Erik
c62663d7cb feat(render): R-A2 — per-building floods (the flap fix)
Replace the outdoor root's single unified reverse-portal flood (whose root-level
portal-side test oscillated as the chase eye grazed a doorway — the measured
flood 2<->6) with retail's per-building floods.

- OutdoorCellNode.Build(uint): portal-less land root; floods only itself ->
  full-screen OutsideView -> terrain (PortalVisibilityBuilder IsOutdoorNode seed).
- PortalVisibilityBuilder.ConstructViewBuilding: per-building flood seeded at a
  building's own finite entrance (retail ConstructView(CBldPortal) 0x5a59a0 via
  DrawPortal 0x5a5ab0 / portal_draw_portals_only 0x53d870). Entrance-bounded ->
  consistent ~2-cell depth (measured retail cell_draw_num, handoff OPTION-A 3.4).
- RetailPViewRenderer.DrawInside: when the root is the outdoor node, group nearby
  cells by BuildingId and merge each per-building flood into the frame before
  assembly; existing shells/object-list draw path unchanged. 48 m seed cutoff.
- GameWindow: pass flat NearbyBuildingCells only on outdoor-node frames.

Tests: +3 PortalVisibilityRobustnessTests (per-building touches ~2 cells, membership
stable under the measured 36 um eye jitter). UnifiedFloodTests retired (its subject,
the unified flood from the outdoor node, is removed); surviving full-screen-OutsideView
coverage moved to OutdoorCellNodeTests. App Rendering 207/207, Core movement 14/14.

Conformance-verified sound; the grazing-doorway flap is the visual acceptance test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:44:43 +02:00
Erik
9b1857ac52 Revert "fix(camera): rest-snap render position — kills the indoor doorway standing-still flicker"
This reverts commit cd974b29bc.
2026-06-08 15:08:25 +02:00
Erik
cd974b29bc fix(camera): rest-snap render position — kills the indoor doorway standing-still flicker
Root cause (pinned live, flap-churn.log at the Holtburg cottage doorway): the physics
body is byte-stable at rest (rawPlayer = 1 distinct value), but
PlayerMovementController.ComputeRenderPosition's Lerp(prev, curr, alpha) dithers the
render position by microns — the two physics-tick snapshots lag the settled body
(per-frame resolve edge-settles the resting sphere against the doorframe after the last
tick wrote curr) while the leftover-accumulator alpha varies every frame. The grazing-
doorframe camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that
~1000x into a ~1.3 mm eye jitter (eye 17 distinct, RenderPosition 15 distinct) that trips
the PortalVisibilityBuilder clip -> the standing-still flicker (blue void / grass over the
cellar entrance) the user reported.

Fix: at rest (body velocity below RestVelocityEpsilonSq) render AT the authoritative
byte-stable body position instead of interpolating between two stale tick snapshots, so the
camera's pivot input is byte-stable and the sweep output stops jittering. Mirrors retail (a
resting object renders bit-stable) + the boom convergence snap
(RetailChaseCamera.ApplyConvergenceSnap, d2212cf), one layer earlier. Sub-tick interpolation
is preserved during motion (velocity above epsilon).

This SUPERSEDES the committed bounded-propagation plan: the live pin proved ZERO portal
re-enqueue churn during the flap (maxPop=1 across 13k oscillating frames; 0/63k reciprocals
ever clipped empty), so the flap was never the churn the spec hypothesized. The
ACDREAM_PROBE_PORTAL_CHURN apparatus did its job (refuted the hypothesis before the wrong
fix was built); plan/spec/memory updates to follow.

TDD: extracted the rest-snap into an internal-static pure ComputeRenderPosition; RED rest-
snap test (stale prev!=curr + varying alpha dithers) -> GREEN after the gate; motion test
guards interpolation; precondition test confirms a settled body's velocity is below the
gate threshold. 29 controller+cellar + 62 camera+portal tests green, no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:59:59 +02:00
Erik
a866c510e3 test(render): deterministic re-pop anchor for the bounded-propagation pin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:52:46 +02:00
Erik
687040ba52 feat(render-diag): add ACDREAM_PROBE_PORTAL_CHURN flag for the bounded-propagation pin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:49:17 +02:00
Erik
6c3a96b26e diag(render): flap re-diagnosed as portal-flood re-clip DRIFT; physics + camera REFUTED
The 2026-06-08 AM "physics rest micro-jitter" diagnosis is refuted with primary
evidence (door-recheck 216K standstill records: 0 position re-snaps; player
byte-stable during the flap). Two adversarial verification sub-agents confirmed:

- Retail roots the render at the camera viewer_cell (swept from the player via
  SmartBox::update_viewer 0x453ce0; DrawInside(viewer_cell) 0x453aa0) and toggles
  DrawInside / LScape::draw -- so acdream's eye-cell rooting + inside/outside
  toggle are RETAIL-FAITHFUL. The locked-design "root at player cell" is wrong.
- The flap is render membership instability, eye-motion-driven: the visible-cell
  set oscillates (8<->3) as the eye sweeps monotonically. Root = the
  re-enqueue-on-growth DRIFT (PortalVisibilityBuilder.cs:322, MaxReprocessPerCell
  =16) re-clipping each grown cell every round -> sub-cm eye jitter flips membership.

Fix (spec, not yet implemented): verbatim port of retail's enqueue-once flood
(ConstructView + AddViewToPortals): enqueue once on first discovery, clip each
cell's portals once, union late growth in place (AddToCell) + draw-reorder
(FixCellList), never re-enqueue. Kills the drift; rooting/camera/seal untouched.

This commit lands VERIFIED GROUNDWORK + design only:
- spec: docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md
- findings: docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md
- [pv-input] probe gains rawPlayer + yaw (disambiguates the varying input)
- 4 GREEN physics rest-stability tests (prove rest is bit-stable -> flap not physics)
- apparatus: launch-flap-capture.ps1, analyze_flap_live.py, find_burst.py
- captured fixtures: tests/.../Fixtures/flap-doorway/0xA9B4017{0..5}.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:21:46 +02:00