Commit graph

315 commits

Author SHA1 Message Date
Erik
59f3a1380d feat(core): Phase W — faithful find_cell_list membership (interior-wins pick + swept determination, drop static :1947)
Change A (TransitionTypes.FindEnvCollisions:~1947): replace the unconditional
static ResolveCellId re-derive with the SWEPT find_cell_list pick via
CellTransit.FindCellSet. When DataCache is available (always in production),
the swept pick runs and resolves the containing cell from the portal-graph
candidate set. When DataCache is null (test engines without a cell registry),
the old ResolveCellId fallback is preserved to keep PhysicsEngineTests green.

Change B (CellTransit.BuildCellSetAndPickContaining): replace the containment
loop that silently skipped all outdoor candidates (CellBSP=null) with the
retail CObjCell::find_cell_list interior-wins pick (pseudo_c:308788-308819):
interior EnvCells win first; if no interior cell contains the center, fall
to the outdoor XY-grid column (CLandCell::point_in_cell equivalent). This is
the missing half of find_cell_list that caused the 0xA9B40170↔0xA9B40031
doorway cell-strobe — the swept pick previously always returned currentCellId
for outdoor candidates, letting the static re-derive at :1947 strobe on every
tick from a different result.

DoorwayMembershipReplayTests: two facts, loads doorway-capture.jsonl (364K records,
strobing live run), filters to Y∈[15.5,17.5] seam zone (57 records), verifies
FindCellSet produces exactly 1 transition (enter indoor → stay outdoors) with
zero A→B→A ping-pong across the full window. Second test verifies outdoor-seed
records round-trip correctly via the XY-grid formula. Both pass.

LiveCompare_FirstCap_FixClosesCottageFloorCap: still passes (issue #98 gate intact).
Full Core suite: 15 failures (within documented flaky baseline of 14–19;
all 15 are pre-existing static-leak/document-the-bug tests, zero new regressions
in cell/transit/BSP/physics classes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:53:30 +02:00
Erik
d23d1f40dc Revert "feat(core): UCG W2 Task 3 — stab-list doorway hysteresis in ResolveCellId"
This reverts commit 2acd8f9e1d.
2026-06-02 14:09:42 +02:00
Erik
851cecc757 feat(core): Phase W Stage 0 — [cell-swept] diagnostic (swept vs static cell, no behavior change)
Add ProbeSweptEnabled (ACDREAM_PROBE_SWEPT=1) to PhysicsDiagnostics mirroring
ProbeCellEnabled. Emits one [cell-swept] line per ResolveWithTransition call —
sp.CurCellId and sp.CheckCellId (the transition's swept cells) alongside the
incoming cellId so a doorway capture shows whether the swept cell is stable
where ResolveCellId strobes. No ResolveCellId call in the probe — avoids the
CellGraph.CurrCell side effect. No behavior change.

TDD: ProbeSweptEnabled_DefaultsToFalse RED→GREEN in PhysicsDiagnosticsTests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:08:00 +02:00
Erik
2acd8f9e1d feat(core): UCG W2 Task 3 — stab-list doorway hysteresis in ResolveCellId
Port retail CObjCell::find_cell_list do_not_load_cells prune
(acclient_2013_pseudo_c.txt:308829-308867) as indoor->outdoor doorway
hysteresis: hold the previous indoor cell when the outdoor candidate is
not in its stab list AND the foot-sphere still overlaps the cell's
containment BSP expanded by DoorwayHoldMargin. Kills the front-door
0170<->0031 ping-pong (handoff §5) the #98 saga never addressed. Fires
only at the front-door seam; the cellar has no exit portal so it never
falls through here (#98 cellar-up untouched).

Three TDD tests in CellGraphMembershipTests: HOLD (the RED->GREEN case,
Y=3.9 inside the 0.2 m margin), RELEASE when fully outside (Y=4.5
exceeds expanded margin), and stab-list gate (outdoor candidate in stab
list releases even near the boundary).

Adds using System.Linq for IReadOnlyList.Contains at the prune site.
SphereOverlapsEnvCell helper mirrors BSPQuery.SphereIntersectsCellBsp
via EnvCell.InverseWorldTransform + ContainmentBsp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:22:50 +02:00
Erik
0e27a6cc3f feat(core): UCG W2 Task 1 — ResolveCellId writes CellGraph.CurrCell (additive)
Add private `SetCurrAndReturn(uint)` helper in PhysicsEngine that looks up
the resolved id in `DataCache.CellGraph` and writes `CurrCell` when the cell
is present.  Wrap the four RESOLVED-id return sites in ResolveCellId:
  - indoor no-CellBSP return (trust FindCellList)
  - indoor sphere-overlaps-CellBSP return
  - outdoor→indoor building-transit return (foreach candidate)
  - outdoor terrain-grid return
The final no-match `return fallbackCellId;` is intentionally NOT wrapped —
stale beats null (the caller's seed is preserved unchanged).

CurrCell has zero readers in src/ (verified by ripgrep); this is additive
write-only, identical observable behavior to W1.  One new unit test
(CellGraphMembershipTests) proves RED→GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:09 +02:00
Erik
8e703bef22 feat(core): UCG Stage 1 — populate CellGraph from CacheCellStruct + AddLandblock (inert)
PhysicsDataCache gains a `CellGraph` property (UCG Stage 1). The env-cell
hook is placed at the very top of CacheCellStruct — before the idempotency
guard and the null-PhysicsBSP early-return — so BSP-less cells are included
in the graph even though they are dropped from the legacy _cellStruct map.
PhysicsEngine.AddLandblock/RemoveLandblock mirror terrain registration into
the graph via a null-guarded DataCache?.CellGraph call. Zero behavior change:
CellGraph has no readers this stage.

A using-alias (UcgEnvCell / UcgCellGraph) resolves the EnvCell name
collision between AcDream.Core.World.Cells and DatReaderWriter.DBObjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:29:30 +02:00
Erik
1aede3d6aa test(core): UCG Stage 1 — real cottage-cell fixture grounding
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:24:37 +02:00
Erik
cf5d60d8fb feat(core): UCG Stage 1 — CellGraph resolver + registry + inert CurrCell
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:21:12 +02:00
Erik
b4c4318c8b feat(core): UCG Stage 1 — LandCell synthesized from TerrainSurface
Outdoor terrain cell (retail CLandCell) synthesized on demand from a
landblock's TerrainSurface. Factory Synthesize() samples four quad
corners to establish Z bounds; PointInCell() tests the 24 m XY quad
in world-local space. BuildingCellId stub is null (Stage 2).
2/2 tests RED→GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:17:57 +02:00
Erik
03f08f00c1 fix(core): UCG Stage 1 — ResolvePortalPolygon all-or-nothing (match BuildLoadedCell)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:15:37 +02:00
Erik
5bc72d5cd1 feat(core): UCG Stage 1 — EnvCell.FromDat derivation (mirrors BuildLoadedCell)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:07:18 +02:00
Erik
76c9e2f07d feat(core): UCG Stage 1 — EnvCell + PointInCell (AABB/BSP)
Adds `EnvCell` (sealed, extends `ObjCell`) with a primitive constructor
and `PointInCell` that uses the cell-containment BSP when present, else
falls back to an AABB test. Retail anchor: CEnvCell (acclient.h:32072).
BSP branch delegates to `BSPQuery.PointInsideCellBsp` (BSPQuery.cs:1034);
the AABB branch is the genuinely new logic. No `FromDat` factory — that is
a separate later task. Consumed by nobody yet (Stage 1 scaffold).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:59:52 +02:00
Erik
9cb15710be feat(core): UCG Stage 1 — ObjCell base + CellPortal
Introduces AcDream.Core.World.Cells namespace with the two foundational
types for the Unified Cell Graph. CellPortal is a readonly struct
unifying the three legacy portal representations; ObjCell is the abstract
base for all traversable cells with the retail id-magnitude IsEnv
discriminator (CObjCell::GetVisible, pseudo_c:308215). Zero consumers;
zero behavior change. 5/5 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:51:55 +02:00
Erik
0b125830fe feat(render): Phase U.2d — ACDREAM_PROBE_VIS visibility probe in RenderingDiagnostics
Add the durable per-frame visibility probe apparatus that #103 lacked, so
the Phase U portal-visibility builder can be validated on live frames before
any GL/visual work.

EmitVis(rootCellId, visibleCells, outsidePolyCount, outsidePlaneCount,
perCellPlaneCounts, scissorFallbacks) prints ONE concise [vis] line gated on
root-cell CHANGE (private _lastVisRootCellId tracker; no-op when the root is
unchanged or ProbeVisibilityEnabled is false — one bool compare per frame when
off). Line format:
  [vis] root=0x… cells=N ids=[…] outside(polys=…,planes=…) percell=[0x…:N,…] fallbacks=…

Reuses the existing Phase A8 ProbeVisibilityEnabled flag (env ACDREAM_PROBE_VIS,
already DebugPanel-mirrored via DebugVM.ProbeVisibility) rather than adding a
parallel owner — Code Structure Rule 5 (one diagnostic owner per subsystem).
Property doc repurposed from the abandoned A8 two-pipe stencil semantics to the
Phase U unified pipeline.

Decoupling note: RenderingDiagnostics lives in AcDream.Core, which must not
reference AcDream.App (Code Structure Rule 2). The plan's EmitVis signature took
an App-layer CellView; this lands the equivalent as pre-computed primitives
(outsidePolyCount + outsidePlaneCount) so the owner stays in Core. The U.4a call
site supplies OutsideView.Polygons.Count and the OutsideView ClipPlaneSet.Count.

TDD: 3 new tests in RenderingDiagnosticsVisibilityTests (no-op when disabled,
fires-once-per-new-root + suppressed-on-unchanged, env-default contract), each
self-contained via internal ResetVisibilityProbeForTests + Console.Out capture
to avoid the documented static-leak flakiness. Core suite +3 tests, no new
failures (flaky physics/input static-leak set unchanged at 16, untouched area).

Courtesy: removed the dangling RenderInsideOutAcdream comment reference (deleted
in U.1) + the AcDream.App.Rendering.Wb doc cref (a Core→App layer inversion).

The emit SITE wiring (per-frame call from the render loop) lands in U.4a; this
task lands only the owner members + formatter + test. GameWindow untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:11:02 +02:00
Erik
3fc77be5de refactor(render): Phase U.1 — delete two-pipe inside-out machinery
Remove IndoorCellStencilPipeline + portal_stencil shaders, RenderInsideOutAcdream,
RenderOutsideInAcdream, the A8-perf instrumentation, the cameraInsideBuilding /
ACDREAM_A8_INDOOR_BRANCH branch, and the dead EntitySet partition values. Collapse
the render branch to the default Draw(All) path (U.4a replaces it with the gated
unified pass). Keep all audited EnvCellRenderer / BuildingLoader / CellVisibility /
camera-collision fixes.

Also deleted with the partition: the two test-only walk helpers
(WbDrawDispatcher.WalkEntitiesForTest / WalkEntitiesForTestByCellIds) and their
test files (WbDrawDispatcherEntitySetTests, WbDrawDispatcherCellIdsOverloadTests),
which existed solely to exercise the removed IndoorPass/OutdoorScenery/
BuildingShells/LiveDynamic partition. EntityMatchesSet / IsShellScopedSet collapse
to the All-path constants; the set: parameter is retained as a seam for the
unified pass.

Note: the depth-clear-if-inside default-path workaround was removed per the
U.1 task list — any current indoor-wall degradation persists until a later
Phase U task lands the unified pass (expected, not a regression introduced here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:05:19 +02:00
Erik
7a244b3291 fix(physics): Phase A8.F — viewer sweeps bypass the 30-step cap (retail-faithful)
Retail's CTransition::find_transitional_position (:273613) has no step
cap. calc_num_steps (:272149) has a dedicated viewer branch `if ((state
& 4) != 0)` at :272181 for sight/viewer objects (ObjectInfoState.IsViewer
= 0x4). The existing acdream cap correctly had a comment "Sight objects
bypass this" but the bypass was never wired — no IsViewer caller existed
until the A8.F camera spring-arm.

With radius 0.3 m the cap fires at ~9 m. The spring-arm sweeps up to
40 m (≈134 steps), so zoomed-out cameras snapped to the player's head
instead of sweeping through geometry. The fix adds `&& !ObjectInfo.IsViewer`
to the guard; non-viewers keep the 30-step safety net (player spheres
~0.48 m radius never exceed 14 m/tick).

Conformance test: radius=0.3, dist=12 (40 steps > 30 cap) over flat
terrain. Normal mover bails (Assert.False). Viewer proceeds to target
(Assert.True + CurPos.X > from.X). RED → GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 20:35:26 +02:00
Erik
fcea05f808 fix(render): Phase A8.F — camera sweep uses retail moverFlags 0x5c (PathClipped hard-stop)
Code review found the probe passed ObjectInfoState.None; retail's
SmartBox::update_viewer calls init_object(player, 0x5c) =
IsViewer|PathClipped|FreeRotate|PerfectClip (pseudo-C :92864). PathClipped makes
the sweep hard-stop at first contact (TransitionTypes.cs:811) instead of
edge-sliding around corners (which would re-trigger the A8.F camera-cell
instability); IsViewer lets the eye pass through creatures, colliding only with
world geometry. Resolves the spec's slide-vs-stop open question. Also reset
CollideCamera in the Defaults_AreRetailValues baseline test (review: maintenance
trap). Spec §5.1/§11.1 synced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:11:53 +02:00
Erik
69c7f8db86 feat(render): Phase A8.F — add CameraDiagnostics.CollideCamera flag (default on)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:00:11 +02:00
Erik
5dc4140c11 feat(render): Phase A8 — indoor visibility + streaming fixes batch
Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:14:50 +02:00
Erik
3361933ce6 feat(render): Phase A8 RR5 — WbDrawDispatcher Draw(cellIds:) overload
Adds a new public overload accepting an explicit IReadOnlyCollection<uint>
cellIds (the camera-buildings' EnvCellIds) instead of a BFS-derived
visibility set. Used by RR7's IndoorPass to scope indoor rendering to the
camera-buildings' cells, not the full portal BFS (which causes Issues A+C).

Pure-data test helper WalkEntitiesForTestByCellIds added alongside the
production overload, mirroring the WalkEntitiesForTest pattern.

The overload internally delegates to the existing visibleCellIds path —
the dispatcher's semantic stays the same; only the caller's intent differs
(explicit cell list vs visibility-derived).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:18:21 +02:00
Erik
55f26f2a9c feat(render): Phase A8 R2 — WbDrawDispatcher.EntitySet taxonomy partition
Reshapes the dormant EntitySet enum from binary IndoorOnly/OutdoorOnly to
a three-way taxonomy-aware partition:

  IndoorPass     — cell mesh + cell statics + building shells
                   (ParentCellId.HasValue OR IsBuildingShell), live-dynamic
                   excluded
  OutdoorScenery — outdoor scenery only (ParentCellId == null AND
                   !IsBuildingShell), live-dynamic excluded
  LiveDynamic    — ServerGuid != 0 (player, NPCs, dropped items)

Centralizes the membership predicate in EntityMatchesSet to keep the three
call sites (two in WalkEntitiesInto, one in WalkEntitiesForTest) DRY.

R1's IsBuildingShell flag is now consumed at render time. Integration into
the render frame ships in R3.

Tests rebuilt from scratch — 7 cases cover the new partition truth table.
Existing dispatcher tests (Tier 1 cache, etc.) continue to pass under the
default EntitySet.All.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:42:09 +02:00
Erik
ed72704f7b feat(world): Phase A8 R1 — tag WorldEntity.IsBuildingShell at LandblockLoader
Adds a bool flag at the WorldEntity data layer set by LandblockLoader from
the source dat array: LandBlockInfo.Buildings → true (cottage walls, inn
walls, smithy walls); LandBlockInfo.Objects → false (trees, lampposts,
rocks, hitching posts).

Retail anchor: CLandBlock::init_buildings reads a separate BuildInfo**
array from objects (acclient.h:31893 num_buildings / buildings field;
acclient_2013_pseudo_c.txt:313854 init_buildings entry). WorldBuilder
preserves the same distinction via SceneryInstance.IsBuilding
(StaticObjectRenderManager.cs:334). Today acdream's loader reads both
arrays into the same WorldEntity pool with no tag, destroying the
distinction (the comment at GameWindow.cs:5175 already acknowledges this
gap for scenery suppression). This commit closes the gap.

Render-time consumption arrives in R2 (EntitySet partition refactor).
Two new LandblockLoader tests lock the tagging behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:31:11 +02:00
Erik
96f8bd2bd7 Revert "fix(render): Phase A8 — animated entities exempt from stencil-gated outdoor pass"
This reverts commit a2ad5c1ac4.
2026-05-26 09:38:37 +02:00
Erik
a2ad5c1ac4 fix(render): Phase A8 — animated entities exempt from stencil-gated outdoor pass
Visual verification of A8 (commit 41c2e67) surfaced a showstopper:
player + NPCs disappeared when the camera entered a building. Root
cause: live server-spawned entities (animated player/NPCs/monsters)
have ParentCellId == null. The EntitySet partition classified them
as "outdoor" and stencil-gated them in the OutdoorOnly pass — so
they only rendered where stencil bit 1 was set (portal silhouettes),
producing partial-body and head-backwards artifacts at doorway
transits and full invisibility everywhere else inside.

Fix: animatedEntityIds overrides the ParentCellId-based partition.
Animated entities always belong in the IndoorOnly pass (stencil OFF),
never in OutdoorOnly. Three changes:
- WalkEntitiesInto full-walk path: compute isAnimated up front, use
  it in both partition checks
- WalkEntitiesInto animated-only path: skip the entire path on
  OutdoorOnly (every iterated entity is animated by definition)
- WalkEntitiesForTest: add optional animatedEntityIds parameter,
  mirror the new partition logic

Two new tests cover:
- EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId
- EntitySet_OutdoorOnly_ExcludesAnimatedEntities

Known remaining limitation: dropped items / static-but-live objects
have ParentCellId == null AND are NOT in animatedEntityIds, so they
still classify as outdoor scenery and stencil-gate. Addressing this
requires a "live entity" flag on WorldEntity — deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:55:45 +02:00
Erik
dcf69a1feb feat(render): Phase A8 — WbDrawDispatcher.EntitySet partition
Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to
partition the per-entity walk by ParentCellId presence. EntitySet.All
preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities;
OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds
filter is still applied on top.

Used by Task 7 to split the render frame's single Draw call into two
(indoor stencil-OFF, outdoor stencil-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:20:45 +02:00
Erik
6577c0a21c feat(render): Phase A8 — RenderingDiagnostics.ProbeVisibilityEnabled
Adds the ACDREAM_PROBE_VIS=1 env-var-toggleable flag for the indoor-cell
visibility culling pipeline (#78). Mirrors the existing ProbeIndoor*
pattern. DebugVM checkbox follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:47:56 +02:00
Erik
a64e6f20da refactor: #100 — remove hiddenTerrainCells / BuildingTerrainCells plumbing
Retired in favour of Task 1's retail-faithful terrain shader Z nudge.
Pure removal — ~50 LOC of dead surface area across:

  - src/AcDream.Core/Terrain/LandblockMesh.cs (drop parameter +
    cell-collapse block)
  - src/AcDream.Core/World/LoadedLandblock.cs (drop field)
  - src/AcDream.Core/World/LandblockLoader.cs (drop method + call)
  - src/AcDream.App/Rendering/GameWindow.cs (3 sites)
  - src/AcDream.App/Streaming/GpuWorldState.cs (6 ctor sites)
  - src/AcDream.App/Streaming/LandblockStreamer.cs (1 ctor site)
  - tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs (drop test)
  - tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs (drop test)

No retail anchor — the deleted mechanism never had one; this commit
rolls our code back to the actual retail behaviour established in
the prior commit's shader nudge.

ISSUES.md #100 moved to Recently closed.

Cross-ref:
  docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
  docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:37:53 +02:00
Erik
6ca872feba docs(test): #101 — sync stale GameWindow.cs line ref in test class doc
Task 2's 11-line insertion shifted the synthesis gate from line
6116 to 6127. The implementation XML doc was updated in the same
commit; the parallel test-class-level reference was missed.
Code-review minor finding; one-character fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:56:35 +02:00
Erik
f6305b1e3c feat(physics): #101 — add IsPhantomGfxObjSource predicate
Retail's CPartArray::InitParts emits collision shapes only from
Setup-level CylSpheres/Spheres or per-Part PhysicsBSP — never
from visual mesh AABBs. The predicate captures the retail rule:
a stab whose source is a GfxObj (high byte 0x01) with no cached
GfxObjPhysics is phantom (no collision). Wired into GameWindow's
mesh-aabb-fallback synthesis in the next commit.

Refs docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:45:18 +02:00
Erik
888272aad1 fix(phys): A6.P7 — retail-binary cyl-vs-BSP dispatch (HAS_PHYSICS_BSP_PS gate)
Closes the door-cyl phantom slide where a sphere approaching a closed
cottage door at NE/SE headings could be blocked by the cyl's radial
normal contaminating the slide tangent into the slab face (live
evidence in door-a6p6-v2.utf8.log: 12 resolves with
cn=(0.86,0.51,0) attributed to door entity 0x000F4245).

Retail anchor: CPhysicsObj::FindObjCollisions at
acclient_2013_pseudo_c.txt:276861 dispatches BINARILY between
BSP-only and cyl+sphere based on HAS_PHYSICS_BSP_PS (0x10000 in
acclient.h:2833). For non-PvP, non-missile movers — every M1.5
scope walking-vs-static scenario — an entity with the flag set
tests its BSP exclusively; the foot cyl is never tested. ACE
confirms the truth table at PhysicsObj.cs:412-450 (HasPhysicsBSP,
missileIgnore, exemption).

Our dispatcher iterated every ShadowEntry independently and tested
both the cyl AND the BSP for a closed door. Cyl was registered
first (FromSetup walk order), and its diagonal radial slide normal
"won" attribution at the early-return on first non-OK. Result was
out=in for tangential motion along the door face.

Changes (~15 LOC + 7 unit tests):
- PhysicsStateFlags.HasPhysicsBsp = 0x00010000 (PhysicsBody.cs)
- Transition.BspOnlyDispatch(uint state) static predicate
  (TransitionTypes.cs) — mirrors retail's branch with M1.5 scope
  defaults (ebp_1 and eax_12 treated as false; wire PvP / missile
  refinements when those scopes ship)
- Per-entry guard in FindObjCollisions cyl/sphere branch
  (TransitionTypes.cs:2433) — continue when BspOnlyDispatch fires,
  with [cyl-skip-bsp] diagnostic line gated on ProbeBuildingEnabled
- A6P7DispatchRulesTests (7 tests, all GREEN): flag value + 6
  parameterized predicate cases

Verification: 14-test keep-green list from the 2026-05-25 handoff
passes (5 BSPQueryTests.FindCollisions_Path5_*, 2 CellTransitTests.A6P5_*,
2 DoorCollisionApparatusTests.Apparatus_DeadCenter_*,
5 DoorBugTrajectoryReplayTests, 1
CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap).
Total: 20/20 pass including the new 7-test predicate suite.

The DocumentsBug test (Apparatus_Grounded_50cmOffCenter) fails
post-fix BUT was already failing pre-fix in the worktree baseline
(verified by stashing the fix and re-running — same failure mode:
sphere blocks at start with floor normal (0,0,1)). Not in the
keep-green list, so this is a known pre-existing condition; the
test's own header comment instructs flipping the assertion when
the fix lands.

Investigation:
docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md

Needs visual verification at Holtburg cottage door (NE/SE approach
should now slide smoothly along the door face — zero [cyl-test]
log lines attributed to door entity, replaced by [cyl-skip-bsp]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:35:32 +02:00
Erik
2a890e6bde test(phys): A6.P5 RED — BFS from indoor cell doesn't reach door outdoor cell
Adds CellTransitTests with two A6P5_* unit tests:
  A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell  (RED — the bug)
  A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell   (passes today)

The RED test reproduces the over-penetration tick's cellSet build:
sphere at (132.594, 16.350) in cell 0xA9B4013F, BFS portal-walks to
0xA9B40150 (alcove) but does NOT add the door's outdoor cell 0xA9B40029.
Pre-fix cellSet: 0xA9B4013F, 0xA9B40150, 0xA9B4014C — no outdoor cells.
Sphere wasn't straddling 0xA9B40150's exit portal so exitOutside stayed
false.

Also removes the 3 A6P5_* replay tests added to
DoorBugTrajectoryReplayTests in the previous commit (3253d84's
follow-up). Those tests didn't reproduce the bug — the harness's
BuildFaithfulDoorEngine has no cell fixtures, so cellSet returned empty
and GetNearbyObjects treated it as "no filter" → door always visible
→ over-penetration test trivially passed for the wrong reason. The
CellTransitTests version pins the bug at the BFS layer directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:51:33 +02:00
Erik
82781c272b test(phys): A6.P5 fixture — 3 ticks from live door-stuck capture
over-penetration-capture.jsonl is 3 records extracted from
door-stuck-capture.jsonl: the cell-crossing over-penetration tick
(0xA9B4013F -> 0xA9B40150, sphere committed 0.27m INTO slab), a
stuck-position hit=yes tick, and a stuck-position hit=no tick. Drives
the A6.P5 replay tests that prove the cellSet gate removal closes both
the over-penetration and the intermittent-visibility bugs.

extract-records.ps1 is a one-shot extractor; reusable if we capture more.
Source captures (door-stuck-*.jsonl, door-stuck-*.launch.log) gitignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:43:51 +02:00
Erik
3253d841ac fix(phys): A6.P4 door — pos_hits_sphere records near-miss polygon
Retail's CPolygon::pos_hits_sphere at
acclient_2013_pseudo_c.txt:322974-322993 records the polygon pointer
(*arg5 = this at line 00539509) on STATIC overlap BEFORE the front-
face cull (dot(N, movement) >= 0 return 0 at line 0053952f). So when
a sphere statically overlaps a wall but is moving parallel/away from
the wall normal, retail returns 0 (no full hit) but the polygon
pointer IS set so Path 5's set_neg_poly_hit dispatch at
acclient_2013_pseudo_c.txt:0053a6ea fires and the outer
transitional_insert loop slides the sphere along the wall.

Pre-fix our PosHitsSphere set hitPoly only when both the static-
overlap AND the front-face cull passed. Near-miss polygons were
dropped → Path 5's `if (hitPoly0 is not null)` branch never fired →
NegPolyHit stayed false → outer loop never slid → inside-out cottage
doors let spheres squeeze through walls they were touching.

The handoff (docs/research/2026-05-25-door-bug-cdb-retail-trace-findings.md)
hypothesized swept-sphere intersection + closest-considered-polygon
tracking. Reading the actual retail decomp of pos_hits_sphere AND
polygon_hits_sphere_slow_but_sure (acclient_2013_pseudo_c.txt:322504-
322635) showed both functions are STATIC tests; the motion vector is
used only for the front-face cull. The fix is a one-line reordering.

Adds 3 unit tests in BSPQueryTests covering:
  - Sphere overlaps wall + moves parallel → NegPolyHit fires (RED→GREEN)
  - Sphere overlaps wall + moves away    → NegPolyHit fires (RED→GREEN)
  - Sphere overlaps wall + moves into    → Slid (regression guard, already
                                            passed)

Verification:
  * 3 new Path 5 tests pass.
  * Full Core suite: 14 failures with-fix vs 17 failures baseline-no-fix.
    The with-fix failure set is a STRICT SUBSET of baseline — zero
    regressions. The 14 remaining failures are pre-existing static-leak
    flakiness between test classes (documented in CLAUDE.md) and 2 stale-
    capture LiveCompare_* document-the-bug tests.
  * All handoff "must-stay-green" tests pass:
    - Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace
    - Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace
    - CornerSlide_AlcoveEastToCottageNorth_ShouldBlock
    - Geometric_DoorSlabAtSphereHeight_OverlapsInZ
    - CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap
      (issue #98 CRITICAL — no regression).

Per CLAUDE.md: needs visual verification at Holtburg cottage door
inside-out off-center (~50 cm) scenario before the A6.P4 phase is
marked complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:05:04 +02:00
Erik
a657ca946c test(phys): A6.P4 door — corner-slide hypothesis falsified, bug is state-related
CornerSlide_AlcoveEastToCottageNorth_ShouldBlock test:
- Registers cottage GfxObj 0x01000A2B (contains north exterior walls)
- Registers cell 0xA9B40150 BSP via dat-direct load (alcove walls)
- Places sphere at (132.95, 16.8, 94) inside alcove near east wall
- Walks sphere +Y 50 times at walk speed (0.05 m/tick)

Result: sphere STAYS at (132.95, 16.8) for all 50 ticks with collision
normal cn=(0.71, -0.71, 0) — the average of alcove east wall normal
and cottage north wall normal at their meeting corner. The corner
handling works correctly in the harness.

So production's inside-out walkthrough is NOT a geometric or BSP
collision-detection bug. The geometry exists, the collision detection
fires symmetrically at corners. The discrepancy must be a STATE
difference between harness and production:
- Real walkable polygons with edges (harness uses big quad)
- Real terrain (harness uses Z=-1000 stub)
- Accumulated body state across many prior ticks (harness uses fresh)
- Possibly cell ping-pong between 0x0150 and 0x0029 in production

Cottage GfxObj wall polygons at the doorway area confirmed:
- North exterior wall east of doorway: polys 0x0032, 0x0033
  X=[133.5, 136.3], Y=17.10, Z=[94, 97], normal +Y
- North exterior wall west of doorway: polys 0x0030, 0x0031, 0x0034,
  0x0035 (X<131.6 various ranges)
- Lintel polys above doorway: 0x0037, 0x0038, 0x003A, 0x003B at Z>96.5

Next-session moves (per handoff):
1. Replay captured tick 2586 (where sphere went from cell 0x0150 to
   0x0029 at X=134.022, way past alcove east wall). Inspect engine
   behavior at exactly that tick's body state.
2. cdb attach to retail at Holtburg cottage doorway — verify whether
   retail also lets sphere walk through at off-center, OR blocks
   cleanly. If retail also allows walkthrough, this might be
   retail-faithful behavior we should accept.

Updated handoff: docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:37:31 +02:00
Erik
fe29db5691 test(phys): A6.P4 door inside-out — locate cottage wall, identify corner-slide hypothesis
Followed up the geometry-gap diagnosis with a wider polygon search.
Result: the cottage's north exterior wall east of doorway DOES exist
in cottage GfxObj 0x01000A2B (polys 0x0032, 0x0033) at
world X=[133.5, 136.3], Y=17.10, Z=[94, 97], normal +Y. Symmetric
polys cover the west side and above the doorway lintel.

The wall SHOULD block sphere at X=133.655 (sphere west edge at 133.175
overlaps wall X range; sphere south edge at 17.11 aligns with wall
at Y=17.10).

New hypothesis: the bug is sphere-vs-corner collision at the meeting
point of cell 0x0150's east wall (X=133.5, Y=[16.5, 17.1]) and the
cottage's north exterior wall (X=[133.5, 136.3], Y=17.10). Cell
transit data shows sphere going from X=132.859 entering alcove to
X=134.022 leaving alcove — sphere reached X=134.022 INSIDE cottage
geometry somehow. The sliding along the slab east face (cn=(+1,0,0)
in captured tick 3254) gradually pushes sphere east. Eventually it
shifts past X=133.5 — the corner where alcove east wall meets cottage
north wall. The corner-handling in our BSP collision may incorrectly
let the sphere slide past, or the alcove cell's east wall and cottage
GfxObj's north wall don't compose correctly at the corner.

Diagnostic apparatus extensions:
- HoltburgLandblockStatics_DatInspection: dumps LandBlockInfo for
  landblock 0xA9B4. Shows 114 stabs + 12 buildings. The cottage IS
  Building[6] with modelId=0x01000A2B (the GfxObj we already loaded).
- Diagnostic_CottagePolys_NearWalkthroughPosition: widened search
  reveals the cottage's full north exterior wall geometry.
- HoltburgCottage_CellPortals_DatInspection: extended with cell
  PhysicsPolygon world-frame dump (already in prior commit).

Full updated handoff: docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md

Next-session move: add a "sphere walks +Y from inside alcove at
X near 133" test. If harness slides past the corner like production,
investigate BSPQuery's sphere-vs-edge case. If harness blocks at
corner, the bug is elsewhere (cell 0x0150 BSP not queried, or
cottage GfxObj BSP traversal misses the wall poly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:34:52 +02:00
Erik
da798b2071 test(phys): A6.P4 door inside-out — collision-geometry gap diagnosis
Added diagnostic apparatus that pinpoints the inside-out walkthrough
as a collision-geometry GAP, not a collision-detection bug.

New tests in DoorBugTrajectoryReplayTests:
- InsideOut_Tick3254_WithCottageWalls_ShouldBlock: hypothesis test that
  registered cottage GfxObj 0x01000A2B and replayed the captured tick.
  Cottage blocked sphere but with cn=(0,0,1) floor-cap normal, not a
  wall normal — first signal that cottage geometry near the sphere
  isn't a wall.
- Diagnostic_CottagePolys_NearWalkthroughPosition: dumps cottage polys
  near sphere XY=(133.655, 17.59) at any Z. Result: ZERO cottage
  polygons in that area. The cottage GfxObj has no geometry where the
  sphere walks through.

DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection
extended to dump cell 0xA9B40150's 4 physics polygons in world frame:
- floor (Z=94), ceiling (Z=96.5), west wall (X=131.6), east wall (X=133.5)
- All walls only span Y=[16.5, 17.1] — the small doorway alcove volume
- North of Y=17.1, no wall

Captured sphere at (133.655, 17.59) is 0.155 m east of cell east wall
AND 0.49 m north of the wall's Y range. No collision geometry exists
at that XY past Y=17.1. The collision representation has a gap that
the visual cottage covers with a wall.

Production capture confirms the diagnosis: cottage GfxObj fires
[bsp-test] 425 times during inside-out walking — visibility IS
correct post-AddAllOutsideCells fix. Door slab fires 245 times. But
the BSP queries find no polygon at (133.655, 17.6+, 94-95.20). The
slab's east face blocks WEST motion (cn=(+1,0,0) as captured), sphere
free to move +Y past it because no wall is there to block.

Three candidates for next-session investigation:
1. Different cottage GfxObj (Holtburg cottages may be multi-piece)
2. Landblock-baked stab static at the cottage exterior wall location
3. Cottage GfxObj's visual polygons wider than physics polygons (dat fact)

Cheapest next step: add LandblockStatics_DatInspection test that
loads LandBlockInfo 0xA9B4FFFE + iterates StaticObjects + prints
every entity at world XY in [131,135] x [16,19]. Reveals what other
entities live at the cottage doorway.

Full handoff: docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:27:52 +02:00
Erik
85a164f4a8 fix(test): correct geometric pin test for door slab Z math
The Geometric_DoorSlabZRange_AbovePlayerSphereTop test was computing
slabWorldZBottom as (entity.Z + partFrame.Z) — assuming the slab's
local Z=0 was its bottom. Actually checking the dat shows the slab's
PhysicsPolygons local AABB is min=(-0.954, -0.134, -1.236) max=(0.971,
0.127, 1.255) — the slab's local origin is at its GEOMETRIC CENTER,
not the bottom. With partFrame.Z=1.275 lifting the origin, the slab
world Z is actually [94.139, 96.630], not [95.375, 97.865].

Corrected test now computes both slabLocalZMin and slabLocalZMax from
the polygon vertices and asserts the opposite (correct) geometric fact:
the slab IS at sphere height — overlap from Z=94.139 to Z=95.20 (1.061
m of vertical overlap with the player's sphere). The slab is NOT a
lintel that misses the sphere; it should collide.

Test renamed: Geometric_DoorSlabZRange_AbovePlayerSphereTop →
Geometric_DoorSlabAtSphereHeight_OverlapsInZ.

Handoff doc 2026-05-25-door-bug-partial-fix-shipped.md updated with
the corrected analysis. The "next investigation candidates" list now
points toward cdb attach to retail as the highest-ROI option, since
the BSP collision IS active at sphere height but production still
shows asymmetric walkthrough behavior. The bug is in either the
GetNearbyObjects coverage at primary-cell boundaries, the BSP
polygon partial-overlap handling, or missing cell-BSP collision for
cottage doorway walls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:14:49 +02:00
Erik
c27fded61e test(phys): A6.P4 door — directional + geometric pin tests reframe inside-out bug
Built three new tests to investigate the inside-out asymmetric collision
that persists after the AddAllOutsideCells coord fix:

1. Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace — sphere
   south of door moving NORTH; expects block with cn.Y less than -0.5
2. Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace — sphere
   north of door moving SOUTH; expects block with cn.Y greater than +0.5
3. Geometric_DoorSlabZRange_AbovePlayerSphereTop — pins the slab Z
   range vs sphere top math

BOTH directional tests PASS — collision is symmetric at unit-test level.
The asymmetric production bug therefore comes from something the unit
tests do not capture (multi-tick state, cell-tracking flicker, walkable
polygon edge interactions).

The geometric pin test reveals the real story: Setup 0x020019FF places
the part-0 BSP slab 1.275 m ABOVE the entity origin via
PlacementFrames[Default][0].Origin. With the cottage door entity at
world Z=94.1, the slab world Z range is [95.375, 97.865]. Player sphere
top reaches Z=95.20. The slab BOTTOM is 0.175 m ABOVE the sphere top —
the slab NEVER collides with the player.

The slab is a LINTEL (door frame above the doorway), not a leaf. The
door's only effective collider at sphere height is the 0.10 m radius
foot cylinder. The directional tests pass because the cylinder blocks,
not the BSP.

User-reported inside-out off-center walkthrough is the sphere walking
AROUND the foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m; any
sphere center over 0.58 m from cylinder center passes freely). The
visual "body partially intersects door" is the character model
occupying the visual door volume while the collision sphere passes
beside the cylinder.

Reframed handoff in docs/research/2026-05-25-door-bug-partial-fix-shipped.md
points to three candidate next-step investigations:
- Retail-faithfulness audit on setup.Radius / setup.Height interpretation
- Re-inspect door parts 1+2 (GfxObj 0x010044B6) for missed physics shapes
- Test the cottage cell BSP (cell 0x0150 walls) + door together — the
  COMBINED collision may be what retail relies on

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:08:42 +02:00
Erik
28cd97be62 fix(phys): A6.P4 door bug — AddAllOutsideCells coord convention + replay apparatus
CellTransit.AddAllOutsideCells assumed sphere coords were absolute world
coords (subtracting lbXf = 0xA9 * 192 = 32448 from the sphere position).
Production has used landblock-local coords since Phase A.1
(streaming-center landblock at world origin), so the subtraction
produced localX = -32316, gridX = -1346 → out-of-range → early return
→ ZERO outdoor cells added.

For outdoor primary cells the bug was masked by GetNearbyObjects's
radial sweep. For indoor primary cells (where #98 gates the outdoor
sweep), the door's outdoor cell 0xA9B40029 never reached
portalReachableCells, the door's BSP was never queried, and the player
walked through Holtburg cottage doors unimpeded.

Fix: AddAllOutsideCells treats worldSphereCenter as landblock-local
directly. Matches retail CLandCell::add_all_outside_cells which uses
the per-cell 6-byte landblock-relative position struct.

Existing CellTransitAddAllOutsideCellsTests + CellTransitFindCellSetTests
updated to use landblock-local sphere coords (they were the only callers
using the world-coord convention; production never did).

Apparatus shipped:
- DoorBugTrajectoryReplayTests — live-capture-driven replay harness
  that pinpointed the bug per-field at unit-test speed (<500ms iteration)
- AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell — direct
  unit test that demonstrates the fix
- FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos
  — verifies cell-portal traversal at the captured sphere position
- DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection
  — dat-direct EnvCell + Environment.Cells + portal-poly inspector
- Fixture: tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl
  (tick 13558 walkthrough + tick 22760 outdoor block)

Visual verification (user-driven at Holtburg cottage door, ~50cm off-center):
- outside→inside RUN: now BLOCKS (was: walks through)
- outside→inside WALK: presumed blocks (not retested)
- inside→outside RUN: PARTIAL — body intersects door, sphere slides through
- inside→outside WALK: same partial behavior

The remaining inside→outside asymmetry is a SEPARATE bug in BSP
collision response for two-sided polygons. The [bsp-test] probe now
fires 245 times for the door entity from indoor (was 0 pre-fix) —
door IS being queried; the BSP polygon-level collision response is
the new bug. Handoff at
docs/research/2026-05-25-door-bug-partial-fix-shipped.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:53:34 +02:00
Erik
163a1f0d35 diag(phys): [bsp-test] probe + grounded apparatus test + handoff
Visual verification of Task 7 ship: doors block at dead-center (the
small Cylinder catches) but the BSP slab doesn't catch off-center
or inside-walking-out approaches. Probe-instrumented live capture
proves multi-part registration is correct — every door spawns with
shapes=cyl1+bsp1, and the BSP part is visited 135 times for a single
door at player approaches as close as 0.42 m, with cacheHit=True.
But zero [resolve-bldg] attributions for the BSP shape.

Three artifacts added:

1. TransitionTypes.cs — new [bsp-test] probe in the BSP collision
   dispatch, fires BEFORE the cache lookup. Mirrors [cyl-test] on
   the Cylinder branch. Distinguishes "cache miss → silent skip"
   from "queried but no hit" (the latter doesn't show up in
   [resolve-bldg] which only fires on attributed hits).

2. DoorCollisionApparatusTests.cs — new grounded test
   (Apparatus_Grounded_50cmOffCenter_*) attempts to reproduce the
   production bug via a seeded PhysicsBody (Contact + OnWalkable
   + ContactPlane + WalkablePolygon). Currently doesn't reproduce
   because the apparatus's stub-terrain + synthetic-floor setup
   diverges from production's real Holtburg geometry. Captured as
   "documents-the-bug" — flip the assertion shape when the fix
   lands.

3. docs/research/2026-05-24-door-collision-task7-shipped-but-bug-remains.md
   — full session handoff. Identifies the remaining bug as a Path 5
   (Contact branch + StepSphereUp) misbehavior at thin tall
   obstacles, not in the multi-part registration we just shipped.
   Leading hypothesis: DoStepUp's downward probe finds the same
   flat floor on the OTHER side of the door (Holtburg cottages have
   no Z change between exterior and interior floor), declares
   step-up success, BSP collision returns OK, sphere walks through.
   Recommended next move: relaunch with ACDREAM_DUMP_STEPUP=1 to
   verify the hypothesis.

What this commit DOES NOT do: fix the remaining step-up bug. The
A6.P4 multi-part registration foundation is correct and stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:22:45 +02:00
Erik
3b7dc46219 fix(phys): GetNearbyObjects dedup-by-entityId silently drops multi-part shadows
Apparatus test (DoorCollisionApparatusTests) loads door GfxObj 0x010044B5
from the real dat, builds the door entity's shape list via
ShadowShapeBuilder, registers via RegisterMultiPart, and sweeps a player
sphere into the door from three angles. Pre-fix: all three assertions
fail — the sphere walks straight through. The [cyl-test] probe fires
every tick (the small Sphere shape is queried) but no [resolve-bldg] —
the per-Part BSP entry is never reached.

Root cause: ShadowObjectRegistry.GetNearbyObjects deduplicates on
entry.EntityId via HashSet<uint>. Pre-RegisterMultiPart each entity had
exactly one shadow row, so dedup-by-entityId correctly suppressed
multi-cell duplication. After Task 4's RegisterMultiPart introduced
multi-shape rows (1 Sphere + 1 per-Part-BSP for doors; potentially more
for creatures + items), the dedup silently drops everything after the
first. ShadowShapeBuilder emits Sphere shapes before Part-BSPs, so the
Sphere wins and the BSP is dropped — exactly the "Task 7 produced zero
[resolve-bldg] hits" finding from the 2026-05-24 evening handoff.

Fix: dedup on the full ShadowEntry. record-struct equality compares
all fields (EntityId, GfxObjId, Position, Rotation, Radius,
CollisionType, CylHeight, Scale, State, Flags, LocalPosition,
LocalRotation). Distinct shapes of the same entity are not equal and
make it through; the same shape registered in multiple cells (its
fields identical across calls) dedups exactly as before.

Apparatus verification post-fix: all 4 tests pass.
  - Dead-center front approach: BLOCKED at Y=11.5 normal=(0,-1,0).
  - 50 cm off-center: BLOCKED at Y=11.5 normal=(0,-1,0).
  - Back approach from inside: BLOCKED at Y=12.8 normal=(0,+1,0).
  - Diagnostic dump: BSP fires at tick 5.

What this fix DOES NOT do: switch live RegisterLiveEntityCollision to
use ShadowShapeBuilder + RegisterMultiPart. That's Task 7 of the
original plan, still reverted. With this foundation fix in place,
Task 7 should now actually deliver door blocking in production.

Test impact: 44/44 in the shape/registry/door scope pass. The broader
Physics suite shows the pre-existing PhysicsResolveCapture
static-state flakiness documented in CLAUDE.md — 6 baseline failures
without my new tests, 10 with them (4 extra are my apparatus tests'
IsPlayer-flag resolves getting captured by a concurrent Capture-test
race). Independent of this fix; verified by isolating each test
class.

Findings + apparatus reasoning:
docs/research/2026-05-24-door-dat-inspection-findings.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:47:04 +02:00
Erik
e1d94d7094 test(phys): door setup + GfxObj dat-inspection — Hypothesis A falsified
Read-only deterministic test that opens the real client dat and
dumps Setup 0x020019FF + every GfxObj id it references. Bypasses
PhysicsDataCache's four early-return filters so we see WHAT is in
the dat, not what got into the cache. Skips gracefully when the
dat directory isn't present (keeps CI green).

Result reframes the prior session's investigation:

GfxObj 0x010044B5 (part 0 of the door) DOES have a full door-slab
PhysicsBSP — 6 two-sided (SidesType=Landblock) polygons forming a
1.925m × 0.261m × 2.490m collision volume at frame[0] offset
(-0.006, 0.125, 1.275). Bounding sphere radius 1.975. HasPhysics
flag set. So the handoff's Hypothesis A ("0x010044B5 has no
collision-bearing polygons, only visual") is FALSE.

GfxObj 0x010044B6 (parts 1 + 2, the swinging leaves) IS visual-only
by retail design — HasPhysics clear, PhysicsBSP null, 0 PhysicsPolygons,
but 87 visual Polygons. Our ShadowShapeBuilder skipping these matches
retail's CPhysicsPart::find_obj_collisions short-circuit on
physics_bsp==0 (acclient_2013_pseudo_c.txt:275051) — not a bug.

So the door collision bug is in INTEGRATION, not data. The Task 7
experiment last session registered 0x010044B5's BSP but got zero
[resolve-bldg] attributions. With the data confirmed good, the
next apparatus is a deterministic harness that hydrates 0x010044B5
from a dat dump, registers it via RegisterMultiPart, and sweeps a
player sphere into the door to confirm whether BSP collision fires
in isolation.

Pickup prompt + full reading in
docs/research/2026-05-24-door-dat-inspection-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:34:41 +02:00
Erik
3e5dc8ce4c test(phys): Task 6 regression — Deregister clears _entityShapes cache
Adds the regression pin for the _entityShapes cleanup that fca0a13
already implemented (Task 4 folded Task 6's Deregister change in for
the multi-part tests to pass). Verifies that a stray UpdatePosition
after Deregister is a no-op — entity is NOT resurrected via the
_entityShapes rebuild path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:22:30 +02:00
Erik
d5ffb0331b feat(phys): UpdatePosition handles multi-part entities
Multi-part entities cached via RegisterMultiPart's _entityShapes now
recompose all part transforms on UpdatePosition (called when the server
broadcasts UpdatePosition (0xF748) for a moving entity). Legacy
single-shape path preserved unchanged for tests + entities that never
went through RegisterMultiPart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:21:35 +02:00
Erik
fca0a13217 feat(phys): ShadowObjectRegistry.RegisterMultiPart
Multi-shape entity registration matching retail's CPhysicsObj model: one
logical entity emits N ShadowEntry rows (one per CylSphere / Sphere /
Part-BSP), all sharing the entity's EntityId. _entityShapes caches the
original shape list per entity for UpdatePosition to recompose part
transforms when the entity moves.

Existing UpdatePhysicsState / Deregister / GetObjectsInCell /
AllEntriesForDebug work unchanged — they iterate by EntityId; multiple
matching entries get handled automatically.

AllEntriesForDebug updated to enumerate all parts per entity (not just
the first) by iterating the first cell that holds entries for each entity.
Single-shape callers that previously relied on deduplicated-by-EntityId
behavior are unaffected since they register exactly one entry per entity.

Six new tests: AllShareEntityId, EmptyShapeList_NoOp,
Deregister_RemovesAllParts, UpdatePhysicsState_PropagatesEtherealToAllParts,
PartsAcrossMultipleCells_AllCellsListed, Register_SingleShapeCompat_Unchanged.

All 24 existing ShadowObjectRegistry tests pass via the unchanged
single-shape Register API. 11/11 CellarUpTrajectoryReplayTests pass.
7/7 ShadowShapeBuilderTests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:19:29 +02:00
Erik
7f5c28777a feat(phys): ShadowShapeBuilder.FromSetup
Pure function translating Setup -> IReadOnlyList<ShadowShape>. Walks
CylSpheres + Spheres (only when no CylSpheres) + Parts (only when the
GfxObj has a non-null PhysicsBSP), using PlacementFrames in the same
Resting -> Default -> first-available priority as SetupMesh.Flatten.

Six tests pin the behavior: door setup produces 4 shapes (0+1+3), sphere
local offset matches Setup data, parts without BSP are skipped, creature
setups with CylSpheres skip Spheres, scale factor multiplies all radii
and offsets, empty setup returns empty list, null setup throws.

No callers in this commit; RegisterMultiPart + the GameWindow callers
follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:12:56 +02:00
Erik
b49ed904c3 feat(phys): A6.P4 slice 1 — portal-reachable cellSet includes outdoor cells
Closes #99 (run-through doors regression from b3ce505).

The b3ce505 stopgap for #98 gates the outdoor 24m radial sweep on indoor
primary cells. Combined with ShadowObjectRegistry.GetNearbyObjects'
"skip outdoor ids" filter on the cellScope-pass loop, this meant doors
registered at outdoor cells (default cellScope=0u for server-spawned
entities at GameWindow.cs:3139) were invisible to spheres on the indoor
side of a doorway threshold — walk-through.

Pre-flight reads found that CellTransit.FindCellSet already adds
outdoor cells to its candidate set when the sphere straddles an
OtherCellId=0xFFFF exit portal (via AddAllOutsideCells triggered by
exitOutside=true inside the indoor-seed BFS). The fix is to stop
filtering those outdoor ids out before iterating, and rename the param
to portalReachableCells to reflect what the set actually contains.

- Q1: Indoor EnvCell.VisibleCellIds is indoor-only in all 16 cottage
  fixtures (low 16 bits ≥ 0x0100). OtherCellId=0xFFFF on portals
  marks "exit to outdoor world" without naming a specific cellId; the
  specific outdoor cell is computed by AddAllOutsideCells from world
  XY when the sphere straddles the exit portal.
- Q2: GameWindow.cs:3139 ShadowObjects.Register for server-spawned
  entities passes no cellScope → default 0u → outdoor 24m grid
  registration. UpdatePosition (line 145) does the same on movement.
  Doors are confirmed outdoor-registered.

Slice 1 makes a smaller change than the spec proposed (no new
parameter; just drop the existing filter), because FindCellSet's
existing exit-portal logic already exposes the needed outdoor cells.
The retail-faithful registration-side BuildShadowCellSet refactor and
the b3ce505 gate removal stay scheduled for slices 2-3.

Verification:
- 24/24 ShadowObjectRegistryTests pass (incl. two new slice 1 tests:
  IndoorPrimary_OutdoorCellInPortalSet_DoorReturned closes #99;
  IndoorPrimary_IndoorOnlyPortalSet_OutdoorRadialStillSkipped
  regression-pins #98)
- 11/11 CellarUpTrajectoryReplayTests pass (LiveCompare_FirstCap_
  FixClosesCottageFloorCap stays green)
- dotnet build AcDream.slnx: 0 errors, 0 warnings
- Pre-existing 6-8 static-state-leakage failures in serial physics
  suite verified unchanged by stash+retest baseline check

Visual verification pending: walk Holtburg cottage doorway from both
sides; door blocks both directions; cellar still climbable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:10:32 +02:00
Erik
b3ce505ca8 fix(phys): A6.P3 #98 — gate outdoor shadow radial sweep on indoor primary cell
The cellar-up cap was caused by ShadowObjectRegistry.GetNearbyObjects
running its outdoor 24m-grid radial query unconditionally — including
when the moving sphere's primary cell is indoor. The landblock-baked
cottage GfxObj 0x01000A2B (registered with cellScope=0u, i.e.
landblock-wide) was returned for a sphere inside the cellar EnvCell,
and its downward-facing cottage-floor poly at world Z=94 head-bumped
the sphere from below, capping ascent at foot Z=92.74.

Diagnosis this session via the live capture in
a6-issue98-resolve-capture-2.jsonl (92K records, 132 cap events all
with body on the ramp polygon) FALSIFIED the prior "stale ramp
contact plane" hypothesis: the contact plane is correctly the ramp's
plane because the sphere IS on the ramp at the cap. The cap is a
proximate consequence of the cottage GfxObj being queried at all from
an indoor primary cell.

Retail decomp anchor (acclient_2013_pseudo_c.txt):
  - 308751-308769: CObjCell::find_cell_list branches on the moving
    object's m_position.objcell_id — INDOOR adds only that cell +
    portal-visible neighbors via CELLARRAY::add_cell; OUTDOOR adds
    all overlapping outdoor cells via CLandCell::add_all_outside_cells.
    Object-position-driven, not sphere-radius-driven.
  - 309560: CEnvCell::find_collisions calls find_env_collisions
    (own cell BSP only) THEN CObjCell::find_obj_collisions on `this`.
  - 308916: CObjCell::find_obj_collisions iterates this->shadow_object_list
    — strictly per-cell, never landblock-wide.

Combined: a landblock-baked static like the cottage building is added
to outdoor cells' shadow_object_list only (its m_position resolves to
an outdoor cell). An indoor EnvCell's shadow_object_list never
contains the cottage. CEnvCell::find_collisions therefore never tests
the sphere against the cottage. Retail-faithful behavior.

Falsification spike (this session): scoping the cottage to a single
distant outdoor cell instead of landblock-wide caused the harness
LiveCompare_FirstCap test to stop reproducing the cn=(0,0,-1) cap,
confirming the cap is caused by the radial sweep returning the
cottage to an indoor primary.

The fix:
  - Add optional `primaryCellId` parameter to
    ShadowObjectRegistry.GetNearbyObjects. When indoor (>= 0x0100),
    skip the outdoor radial sweep entirely after the indoorCellIds
    branch runs. Default 0u preserves prior behavior for
    cell-unaware callers (existing tests pass unchanged).
  - Transition.FindObjCollisions passes sp.CheckCellId.
  - Harness LiveCompare_FirstCap_* flipped to documents-the-fix form
    (asserts the downward-facing cottage-floor cap does NOT fire).
    Deletes the residual-X-motion test that documented a post-cap
    edge-slide — irrelevant once the cap is gone.

This same gate should close the other "Finding 3 family" indoor/outdoor
collision bugs (#97 phantom collisions, indoor sling-out). Visual
verification by the user is the remaining acceptance check before
closing #98.

Verification:
  - 11/11 CellarUpTrajectoryReplayTests pass in isolation
  - 55 ShadowObjectRegistry + TransitionTypes + PhysicsEngine
    + CellPhysics + CellTransit tests pass
  - 8 pre-existing static-state-leakage failures in serial physics
    suite are unchanged (verified by stash + retest on baseline)
  - dotnet build clean, 0 warnings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:49:46 +02:00
Erik
97fec19dbb test(phys): A6.P3 #98 — comparison harness reproduces cottage-floor cap
Apparatus convergence. With the cottage GfxObj 0x01000A2B registered as
a ShadowEntry in BuildEngineWithCellarFixtures, the harness now reproduces
the live cap-event collision normal (cn=(0,0,-1)) exactly, ending the
"harness doesn't reproduce" divergence the prior session's findings doc
identified.

Concretely:
  * Adds a minimum-stub landblock (TerrainSurface at z=-1000) so
    TryGetLandblockContext succeeds at the cellar XY — production's
    FindObjCollisions early-returns without a landblock and would skip
    the cottage shadow query.
  * Adds RegisterCottageGfxObj that loads the 74-polygon cottage fixture
    via GfxObjDumpSerializer.Hydrate, then registers it at the cottage's
    world transform (translation (130.5, 11.5, 94.0) + 180° around Z,
    derived from the cellar cell's WorldTransform), matching
    GameWindow.cs:5893's landblock-baked-static registration shape.
  * LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered
    flips: the cap-normal reproduction is now enforced by
    LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal.
  * The full per-field round-trip uncovered ONE residual divergence:
    live preserves +0.0266m of +X motion through the cap event (edge-
    slide along the floor in XY); harness blocks ALL motion at the cap.
    Captured by LiveCompare_FirstCap_ResidualXMotionDivergence_Docs...
    in documents-the-bug form so the next session has a concrete next
    target.

Fixture: tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json
(74 polygons, 6 downward-facing cottage-floor triangles at object-local
Z=0, BSP radius 13.989m matching the live [resolve-bldg] bspR=13.99).
Captured via launch-a6-issue98-cottage-gfxobj-dump.ps1.

In-isolation: all 12 CellarUpTrajectoryReplayTests + 4 GfxObjDumpRoundTripTests
+ 1 new PhysicsDiagnosticsTests pass.

Note on full-suite baseline: the full xUnit serial run shows 8–19
failures depending on order (pre-existing test interaction with shared
statics across PlayerMovementControllerTests, MotionInterpreterTests,
PositionManagerTests, etc.). The flakiness is independent of this
change — confirmed by stashing the harness changes and observing the
same flaky range. Investigating the static-state isolation problem is
out of scope for issue #98; tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:44:50 +02:00
Erik
cc3afbcbeb feat(phys): A6.P3 #98 — GfxObj dump infrastructure (ACDREAM_DUMP_GFXOBJS)
Mirror the existing ACDREAM_DUMP_CELLS pattern for GfxObj-owned geometry:
when ACDREAM_DUMP_GFXOBJS lists a hex GfxObj id, the first
PhysicsDataCache.CacheGfxObj for that id writes the full resolved
polygon table to a JSON fixture under
tests/AcDream.Core.Tests/Fixtures/issue98/0x{id:X8}.gfxobj.json (override
dir via ACDREAM_DUMP_GFXOBJS_DIR).

Motivation: the existing [resolve-bldg] probe captures GfxObj-level
metadata (id, BSP root radius, entity origin) but emits
"hitPoly: n/a (BSP path — side-channel not written)" because the
BSPQuery wire site that would populate LastBspHitPoly never landed.
A polygon-level dump at cache time bypasses that gap — one capture run
yields the FULL polygon table, fixture-loadable by the harness's
RegisterCottageGfxObj helper (next commit).

See docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
for the cottage GfxObj 0x01000A2B context: landblock-baked static at
entity origin (130.5, 11.5, 94.0), responsible for the head-sphere cap
from below at world Z=94.0 that issue #98 is documenting.

Test baseline: 1183 + 8 pre-existing failures (serial run; +5 new tests
all pass; was 1178 + 8 pre-session).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:24:26 +02:00