Commit graph

762 commits

Author SHA1 Message Date
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
1454eab75a feat(phys): ShadowEntry adds LocalPosition + LocalRotation
Local-to-entity transform fields, default-valued so existing single-shape
callers keep working unchanged. RegisterMultiPart (next commit) populates
them per part so UpdatePosition can rebuild the entry's world Position +
Rotation when the entity moves.

All 24 existing ShadowObjectRegistry tests pass (including the 2 new
slice 1 tests from b49ed90).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:14:20 +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
ab4278c272 feat(phys): add ShadowShape record (no callers yet)
Standalone record representing one collision-bearing shape attached to
a logical PhysicsObj. Foundation for the per-part BSP collision fix
that closes the M1.5 "doors don't block" bug. Spec at
docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md.

No callers in this commit; integration follows in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:07:40 +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
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
Erik
fb5fba6229 test(phys): A6.P3 #98 — live ResolveWithTransition capture apparatus
Apparatus only — no fix attempt. Per the systematic-debugging skill's
"3+ failures = question architecture" rule, the 6 hypotheses we
tested speculatively on the harness's airborne-at-tick-1 bug all
failed because we kept guessing what state the harness lacks. This
commit ships the evidence-driven path: capture the EXACT player
ResolveWithTransition call (every input + body-before + body-after +
result) into a JSON Lines fixture, then a comparison test loads the
fixture and replays it against the test engine. The first per-field
divergence pinpoints the missing apparatus state — no more guessing.

Adds:
- src/AcDream.Core/Physics/PhysicsResolveCapture.cs — new static module
  with CapturePath (env var ACDREAM_CAPTURE_RESOLVE), PhysicsBodySnapshot
  record, JSON Lines writer (thread-safe, flushes per record), process-
  exit hook for clean shutdown.

- PhysicsEngine.ResolveWithTransition probe wiring: snapshot body at
  method entry, snapshot again before return, refactor the two returns
  into one path so the capture call site is single. Filtered to
  IsPlayer mover flag so NPC/remote DR calls don't pollute.

- CellarUpTrajectoryReplayTests.cs:
  • Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled — drives 3
    ticks with capture on, reads file back, verifies round-trip of
    inputs + body-before/after snapshots.
  • Capture_SkipsNonPlayerCalls — drives 3 NPC-style ticks (no
    IsPlayer flag), confirms the file is not created.

Off by default. Set ACDREAM_CAPTURE_RESOLVE=<path> to a writable file
path; capture starts on the next player ResolveWithTransition call.

Test baseline: 1172 + 8 pre-existing failures + 2 new smoke tests
that pass = 1174 + 8. Verified by stashed-baseline comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:41:11 +02:00
Erik
402ec10ec5 Revert "fix(phys): A6.P3 #98 — gate ContactPlane assignment by Normal.Z (Shape 1)"
This reverts commit 0cb4c59681.
2026-05-23 16:54:19 +02:00
Erik
0cb4c59681 fix(phys): A6.P3 #98 — gate ContactPlane assignment by Normal.Z (Shape 1)
Adds PhysicsGlobals.ContactPlaneFlatThreshold = 0.99f and uses it at
both BSPQuery.AdjustSphereToPlane call sites that previously set CP
unconditionally on any walkable polygon found by FindWalkableInternal.

Backed by the retail cdb capture in cellar_up_capture_1: across 161
set_contact_plane writes during 5 seconds of cellar-up climbing,
EVERY write lands on a flat (Normal.Z = 1.0) plane — cellar floor at
world Z=90.95 or cottage floor at world Z=94. The cellar ramp
(Normal.Z = 0.695, walkable per FloorZ but sloped ~46 degrees) is
never set as CP in retail.

Acdream's prior behavior of setting CP=ramp caused two cascading
issues at the top of the ramp:
1. AdjustOffset's slope-projection produced +Z gain per call (correct
   in isolation) but inflated step-up's responsibility to "find the
   next walkable below the lifted check position".
2. step-up's downward step-down probe found no walkable within 0.6m
   below the proposed check (cottage floor at Z=94 is ABOVE, not
   below), so step-down rejected, sphere rolled back. Infinite freeze
   at world Z ~= 92.80.

With CP only set on flat polygons, sloped surfaces drive collision
detection and walkable-poly tracking (via path.SetWalkable) but
don't override the resting CP. The sphere should now climb the ramp
via step-up over the ramp polygon, with CP staying on the flat
cellar floor until the sphere reaches the flat cottage floor.

Tests: 1167 + 8 baseline maintained. No regression. The Issue98
replay tests still pass — they document the failing-frame geometry
(sphere world Z=92.01 below cottage floor), which doesn't change;
the fix prevents the sphere from getting STUCK at that altitude in
the first place. Live visual verification required next.

If the live test shows new failure modes (sphere stuck somewhere
else, doesn't climb at all, climbs but slides off, etc), the
threshold (0.99) or the gating approach itself may need refining.
This is the conservative empirical version of Shape 1; the named-
decomp research did not conclusively prove the exact retail gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:46:12 +02:00
Erik
8a232a3e6e diag(phys): A6.P3 #98 — [step-walk-adjust] probe inside AdjustOffset
Adds one log line per AdjustOffset call (gated by ACDREAM_PROBE_STEP_WALK)
naming the branch taken (no-cp / no-cp-slide / slide-degenerate /
slide-crease / into-plane / away-plane, optionally +safety-push) plus
zGain = output.Z - input.Z.

No math or control-flow changes — pure observability so the next capture
can disambiguate the three failure-mode hypotheses for the cellar-ramp
climb cap. Re-reading the existing capture (a6-issue98-negpoly-...log)
showed the sphere DOES climb 90.00 -> 92.79 (2.79 m gain), then caps,
contradicting the divergence comparison's "no altitude gain" framing.
The real question is what stops the climb at world Z ~= 92.79 with the
cottage floor still 1.21 m higher. Existing [step-walk] probes wrap
AdjustOffset; this new probe reveals which branch the projection takes.

Fix plan with the four-branch decision tree at
docs/superpowers/plans/2026-05-23-a6-p3-issue98-cellar-up-fix.md.

Test baseline maintained: 1167 + 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:16:42 +02:00
Erik
f62a873be3 feat(phys): A6.P3 #98 Step 2 — cell-dump probe + roundtrip test
Step 2 of the apparatus plan at
C:\Users\erikn\.claude\plans\i-did-some-work-sharded-acorn.md. Adds a
one-shot cell-dump probe so the issue #98 replay harness can load real
cellar / cottage geometry as JSON fixtures, eliminating live-client
iteration from every fix attempt.

Probe gate:
  ACDREAM_DUMP_CELLS=0xA9B40143,0xA9B40146,0xA9B40147
  ACDREAM_DUMP_CELLS_DIR=tests/AcDream.Core.Tests/Fixtures/issue98 (default)

When set, the first time PhysicsDataCache.CacheCellStruct sees a matching
envCellId, it serializes the resulting CellPhysics to
<dir>/0x<cellid>.json and prints one [cell-dump] line. Zero cost when
unset (gate is a static-readonly IReadOnlySet<uint>.Count check).

DTOs (CellDump.cs):
- CellDump: top-level record holding cell id, WorldTransform,
  InverseWorldTransform, resolved polygons, portal polygons, portal
  infos, visible cell ids.
- PolygonDump / PortalDump / Vector3Dto / PlaneDto / Matrix4x4Dto:
  System.Text.Json-friendly records with explicit From / To converters.

What is intentionally NOT dumped: the DAT-native PhysicsBSPTree and
CellBSPTree trees. The replay harness drives the leaf-level walkable
predicates (WalkableHitsSphere, FindCrossedEdge, PolygonHitsSpherePrecise)
directly on the resolved polygon list, which is enough to expose the
issue #98 rejection (poly 0x0004 in 0xA9B40143 reports
insideEdges=False / overlapsSphere=False at the failing-frame sphere).
If a future replay needs BSP traversal we can extend the DTO + Hydrate
together without breaking fixtures.

Tests (CellDumpRoundTripTests):
- Capture → Hydrate preserves WorldTransform / InverseWorldTransform /
  every polygon's plane + vertices + NumPoints + SidesType.
- Capture → Hydrate preserves portal list + visible cell ids.
- Write to disk → Read back → Hydrate preserves content.
- Hydrate leaves BSP / CellBSP null by design (replay uses leaf-level
  predicates).

Verification:
- dotnet build: green, 0 errors.
- dotnet test: 1160 passed + 8 pre-existing failed (was 1156 + 8 before
  this commit; +4 from CellDumpRoundTripTests). Same 8 pre-existing
  failures, no new regressions.

Next: capture the three cells from the live client (Step 2 acceptance),
then build the replay harness against the fixtures (Step 3).
2026-05-23 15:16:56 +02:00
Erik
35b37dfb5f chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments
Triage step from the plan at C:\Users\erikn\.claude\plans\
i-did-some-work-sharded-acorn.md. Four sessions on issue #98 left the
worktree dirty with ~1352 LOC of mixed work. This commit splits the
work into "keep" (defensible + diagnostic) and "drop" (failed
experiments), then commits the keep set with the drops removed.

Plan asked for three commits (diag / fix / revert); consolidated to one
because the diagnostic emits in TransitionTypes.cs are tightly
interleaved with the multi-sphere CellTransit calls and the CellId
switch. Hunk-level splitting in those files for marginal bisect
granularity didn't justify the misclick risk.

Reverted entirely (failed experiments per slice 7 handoff):
- src/AcDream.Core/Physics/PhysicsDataCache.cs — neg-poly storage
  fields (Stippling, PosSurface, NegSurface, HasNegativeSide,
  IsNegativeSide, NegativeSide).
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs — isBuilding flag
  propagation through Register / ShadowEntry.
- tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — 165 lines of
  PolygonWithNegativeSide_* tests.
- tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs —
  isBuilding propagation tests.
- src/AcDream.Core/World/WorldEntity.cs — IsLandblockBuilding field
  (no consumer once ShadowObjectRegistry.isBuilding is gone).
- src/AcDream.Core/World/LandblockLoader.cs — IsLandblockBuilding=true
  setter on building entities (kept BuildBuildingTerrainCells).
- src/AcDream.App/Rendering/GameWindow.cs — isBuilding: arg passed to
  ShadowObjects.Register.
- src/AcDream.Core/Physics/BSPQuery.cs — TryAdjustWalkableSide /
  IsWalkableAt helpers, their callers, the Path 5 / Path 6 neg-poly
  branch split, the BldgCheck-tied clearCell conditional, and the
  neg-poly ResolveCellPolygons writes.
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — neg-poly fields
  in the poly-dump format.
- src/AcDream.Core/Physics/TransitionTypes.cs — SpherePath.BldgCheck +
  SpherePath.HitsInteriorCell fields and every consumer, the
  savedBldgCheck try/finally around FindCollisions, and the neg-poly
  format additions to the dump-on-error helper.
- src/AcDream.Core/Physics/CellTransit.cs — FindCellSet overloads
  with hitsInteriorCell out-param and the BuildCellSetAndPickContaining
  out-param threading.

Kept (defensible correctness fixes + diagnostic infrastructure):
- src/AcDream.App/Rendering/GameWindow.cs — render-vs-physics cell
  origin split: the 0.02m render lift no longer leaks into physics
  BSP caching. lb.BuildingTerrainCells threaded into LandblockMesh.Build.
- src/AcDream.Core/World/LoadedLandblock.cs — BuildingTerrainCells
  record field.
- src/AcDream.Core/World/LandblockLoader.cs — BuildBuildingTerrainCells
  (cy*8+cx from LandBlockInfo.Buildings).
- src/AcDream.Core/Terrain/LandblockMesh.cs — hiddenTerrainCells
  param that collapses owned-cell triangles to a zero-area degenerate.
- src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs —
  mechanical BuildingTerrainCells threading through LoadedLandblock
  reconstructions.
- src/AcDream.Core/Physics/CellTransit.cs — multi-sphere
  FindTransitCellsSphere variant + multi-sphere AddAllOutsideCells +
  FindCellSet(IReadOnlyList<Sphere>, …) overload + the
  BSPQuery.SphereIntersectsCellBsp call for loaded neighbours. Matches
  retail CObjCell::find_cell_list / CEnvCell::find_transit_cells.
- src/AcDream.Core/Physics/TransitionTypes.cs — multi-sphere FindCellSet
  call site, retail-faithful CellId switch after CheckOtherCells, the
  outdoor-landcell terrain-walkable fallback in CheckOtherCells, and
  the full diagnostic suite ([step-walk], [walkable-nearest],
  [issue98-walkable-detail], [cell-set-summary], LastBspHitPoly
  emits).
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — ProbeStepWalkEnabled
  gate (ACDREAM_PROBE_STEP_WALK=1) + LogStepWalk helper + FormatVector
  / FormatPlane utilities. All emit-gated.
- src/AcDream.Core/Physics/BSPQuery.cs — diagnostic emits to
  LastBspHitPoly at four sites in SphereIntersectsPolyInternal /
  the placement adjustment path.
- Test files for the kept work: CellTransitFindCellSetTests,
  CellTransitFindTransitCellsSphereTests, PhysicsDiagnosticsTests,
  TransitionCheckOtherCellsTests, LandblockMeshTests,
  LandblockLoaderTests.

Verification:
- dotnet build: green, 0 errors, 3 pre-existing warnings.
- dotnet test: 1156 passed + 8 failed (baseline was 1148 + 8 pre-
  existing; the +8 passing are the new tests for the kept defensible
  work). Same 8 pre-existing failures, no new regressions.

Backup of pre-triage worktree state in stash@{0}.

A6.P3 #98 is still open; this is the apparatus-prep step, not a fix.
Next: cell-dump probe (Step 2 of the plan).
2026-05-23 15:11:49 +02:00
Erik
cf3deff7c2 feat(phys): A6.P3 slice 5 — [place-fail] probe + sharpened #98 diagnosis
Add ACDREAM_PROBE_PLACEMENT_FAIL gate + LogPlacementFail emitter +
side-channel polygon attribution in PhysicsDiagnostics. Wire into
BSPQuery.FindCollisions Path 1 (Placement/Ethereal) on Collided
returns; wire into Transition.DoStepDown after the placement_insert
TransitionalInsert(1) call; wire into Transition.FindObjCollisions
to emit per-static-object [place-fail-obj] lines.

Run scen4 cellar-up with the probe → 168 [place-fail] events. 80 of
81 BSPQuery Path 1 placement rejections cite polygon 0x0020 in
cellar cell 0xA9B40147's BSP: n=(0,0,-1) d=-0.2, world Z=93.82 —
the cellar ceiling (underside of cottage main floor thickness layer).
0 [place-fail-obj] lines, confirming the failure source is the cell
BSP not a static object.

The probe-driven evidence INVALIDATES the 2026-05-22 morning
handoff's "Path 5 vs Path 6 in BSPQuery.FindCollisions" diagnosis.
Retail's BP4 trace shows every find_collisions hit has collide=0 —
retail enters the same Contact branch we do, no outer-dispatcher
divergence. Retail's BP5 fires 17+ times on the cellar ramp polygon,
not "30 hits all on flat planes" as morning claimed.

The actual divergence is downstream in cell-promotion: retail's
check_cell transitions to cottage cell 0xA9B40146 during the ascent
(BP7 sets ContactPlane to the cottage main floor poly, which lives
in cottage cell's BSP not cellar's). Ours stays at cellar 0xA9B40147,
where the ceiling poly 0x0020 correctly rejects the lifted sphere.

No fix attempted this session per CLAUDE.md discipline check
(3+ failed fixes = handoff). Full slice 5 evidence + concrete
next-session pickup steps at docs/research/2026-05-22-a6-p3-slice5-handoff.md.
ISSUES.md #98 updated with the corrected diagnosis.

Test baseline: 1148 + 8 pre-existing fail. Maintained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:02:15 +02:00
Erik
bbd1df46e0 fix(phys): A6.P3 slice 4 — reset WalkInterp before placement_insert (issue #98)
Hypothesis-driven fix for issue #98 (cellar ascent stuck at top step).

Symptom (from polydump trace at scen4_cottage_cellar_polydump):
- Player walks up cellar ramp (real 46-degree slope per dat verification)
- Hits ramp polygon 0x0008 in cellar cell 0xA9B40147 270 times
- Each hit: sphere center lifted 0.75m onto ramp surface, all walk_interp
  consumed (winterp 1.0 -> 0.0)
- Step_up_slide fires 159+ times trying to recover
- Player physically stuck — never advances forward

Bug hypothesis: in DoStepDown, after the primary TransitionalInsert(5)
probe consumes WalkInterp down to 0 (the 0.75m lift), the placement_insert
call runs with WalkInterp=0. AdjustSphereToPlane's interp check
(`interp >= path.WalkInterp` where interp=0) then rejects any push-back
needed for the placement validation -> placement fails -> step_up returns
failure -> step_up_slide loop -> player stuck.

Fix: reset WalkInterp = 1.0 before the placement_insert call (mirrors
retail step_down's walk_interp = 1 reset at function entry, which is what
the placement_insert runs after in retail's flow).

Test suite: 1148 + 8 (baseline maintained).
Visual verification: pending user re-test of cellar-up walk.

If this fixes cellar-up, also likely improves other step-up-onto-slope
scenarios. If it doesn't fix cellar-up, the bug is elsewhere in the
step-up/step-down flow (separate investigation needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:07:06 +02:00
Erik
8bd311759e fix(phys): A6.P3 slice 3 v3 — REVERT stickiness; hypothesis-test cellar-up
Slice 3 v2 (point-in stickiness) closed the cell-resolver ping-pong
(data confirmed: scen4_cottage_cellar_slice3v2 capture shows 1 cell-
transit vs 20+ pre-fix). BUT user verification revealed: cellar-up
symptom transitioned from "stuck-at-top-ping-pong" (pre-slice-3) to
"never-reach-top-stuck-in-cellar" (post-slice-3). Stickiness was
holding player in cellar cell so aggressively that the legitimate
transition to the cottage main floor cell at the ramp top never
fired.

Reverting the stickiness check entirely. Trade-off:
- Inn doorway ping-pong returns (existed pre-slice-3; lesser evil)
- Player can again reach the top of the cellar ramp (per pre-slice-3
  user observation)
- Issue #98 cellar-up remains open — but with sharper diagnosis: it's
  not the cell resolver at all, it's deeper (BSP step-physics or
  AdjustOffset slope-projection at the cottage main floor boundary,
  per slice 4 polydump trace showing repeated push-back on the
  46-degree ramp polygon)

The slice 3 stickiness premise was correct but the implementation
shape was wrong. A future attempt needs either:
- A "near boundary" gate (only stick when sphere is deep inside cell)
- A retail-faithful per-cell hysteresis matching CObjCell::find_cell_list
  Position-variant (acclient_2013_pseudo_c.txt:308742-308783) more
  exactly than point-in
- OR address the underlying BSP step-physics bug first; then ping-pong
  may not even need a stickiness fix

Test suite: 1148 + 8 (baseline maintained).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:43:58 +02:00
Erik
319847289e diag(phys): A6.P3 slice 4 — extend [cell-cache] probe with portalTargets
Extends the existing [cell-cache] probe (gated by ACDREAM_PROBE_CELL_CACHE=1)
to also dump the list of portal targets per cell: which other cells
each portal connects to, the portal polygon id, and the flags.

Output format (appended to existing [cell-cache] line):
  portalTargets=[(cell=0xNNNN,poly=0xNNNN,flags=0xNNNN),...]

Purpose: investigating issue #98 (cellar-up stuck at top of ramp).
We now know the polygon geometry is correct (per slice 4 polydump
capture: the cellar is a real 46° ramp). Question is whether the
cellar cell has a portal to the cottage main floor cell, and where
that portal is. If portalTargets shows no connection to the expected
upstairs cell, that's the bug.

Test suite: unaffected (probe is gated off by default).
Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:31:34 +02:00
Erik
0b449968a7 diag(phys): A6.P3 slice 4 — add [poly-dump] probe for #98 investigation
Adds a polygon-geometry dump probe that fires alongside [push-back]
whenever AdjustSphereToPlane lands a push-back. Gated by
ACDREAM_PROBE_POLY_DUMP=1.

Output format:
  [poly-dump] cell=0xA9B40147 polyId=0x0042 numPts=4 sides=Single
              n=(0.000,-0.719,0.695) d=-0.1007
              verts=[(x1,y1,z1),(x2,y2,z2),(x3,y3,z3),(x4,y4,z4)]

Purpose: investigate #98 (cellar-up stuck at top step). The push-back
trace shows the player hitting a sloped surface n=(0,-0.719,0.695) at
the cellar stair top. Two possibilities:
  1. The polygon really IS sloped 44° in the dat (genuine geometry).
  2. Our dat-read produces wrong vertices → wrong normal → wrong plane.

The dump lets us:
- Identify which dat polygon was hit (cell + poly ID)
- Compare our extracted vertices against WorldBuilder's straight-from-
  dat read for the same poly
- Or spawn the cell in ACViewer to visually verify the geometry

Changes:
- Added `ushort Id` property to ResolvedPolygon (defaults to 0 for test
  fixtures that don't care; production code in PhysicsDataCache.cs +
  BSPQuery.cs sets it from the dictionary key).
- Added ProbePolyDumpEnabled + LogPolyDump in PhysicsDiagnostics.
- Wired the dump into AdjustSphereToPlane's apply-branch (after the
  existing push-back log; same gating pattern).

Test suite: 1148 pass + 8 pre-existing fail (baseline maintained).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:17:47 +02:00
Erik
3e140cfe71 fix(phys): A6.P3 slice 3 v2 — point-in stickiness (was sphere-overlap)
Slice 3 v1 (8898166) used SphereIntersectsCellBsp for the stickiness
check. User verification showed: ping-pong WAS closed (3 cell-transit
events vs 20+ pre-fix) but user still couldn't walk up out of cellar
because the stickiness was OVER-CORRECTED — the sphere still partially
overlapped the cellar cell at the top of stairs, so stickiness held the
player in the cellar even when the center had transitioned to the
cottage main floor cell.

Fix: switch the stickiness check from SphereIntersectsCellBsp (sphere
overlap) to PointInsideCellBsp (center-in). Matches FindCellList's
own semantics for "which cell are you in." Player stays in fallback
only while center is still inside fallback's BSP volume.

Trade-off:
- More permissive transitions (good — cellar-up works)
- Less aggressive stickiness, so some boundary ping-ponging may return
  IF the sphere center oscillates across the boundary (rare; would
  require sub-mm Z drift across the boundary line)

If the trade-off bites (ping-pong returns somewhere), the fix is a
small geometric margin around the point-in check — but verify before
adding.

Test suite: 1148 pass + 8 pre-existing fail (baseline maintained).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:04:51 +02:00
Erik
88981669fe fix(phys): A6.P3 slice 3 — cell-resolver stickiness for ping-pong fix
Closes A6.P2 Finding 3 (cell-resolver instability) + issue #98 (cellar
ascent stuck at last step) + likely closes #97 (phantom collisions +
fall-through on 2nd floor; same instability family).

Adds a cell-stickiness check at the top of ResolveCellId's indoor
branch: before re-resolving via FindCellList, check if the fallback
(previous-tick) CellId's BSP still validly contains the sphere. If
yes, return fallbackCellId immediately — preserves cell membership
when the sphere is at a boundary where multiple cells overlap.

The bug: at cell boundaries (cellar last step, indoor doorways,
between two adjacent indoor cells), the sphere overlaps multiple
cells geometrically. FindCellList's candidate-iteration order
(HashSet, implementation-defined) determines which cell wins. That
order may shift tick-to-tick → CellId ping-pong → AdjustOffset
operates against a different cell's geometry each tick → player
can't accumulate forward motion → stuck.

Evidence: scen3_inn_2nd_floor_slice2v2 capture shows the ping-pong
chain at the cellar boundary:
  0xA9B4014B → 0xA9B4014A → 0xA9B4013F → 0xA9B4014A → 0xA9B4014B
  (Z stable ~96.4; CellId oscillates every tick; reason=resolver)

Retail oracle: cell-array hysteresis pattern from
CObjCell::find_cell_list Position-variant at
acclient_2013_pseudo_c.txt:308742-308783. Retail preserves cell
membership when sphere is close to (but slightly past) cell
boundaries.

Implementation: 9 lines added (sphere-overlap check against
fallbackCellId's CellBSP before falling through to FindCellList).
Existing #90 workaround at line 299-300 (post-FindCellList sphere-
overlap check) is now redundant in the common case but kept for
safety; deferred to A6.P4 removal after visual verification.

Test suite: 1148 pass + 8 pre-existing fail (baseline maintained).
Visual verification: pending — user happy-test will confirm cellar-
up walk succeeds + no ping-pong in cell-transit log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:01:28 +02:00
Erik
f8d669be88 fix(phys): A6.P3 slice 2 v2 — revert seed removal + add no-op guard
First slice 2 attempt at commit 892019b removed PhysicsEngine.cs L622
per-tick CP seed entirely. User happy-test surfaced a regression: BSP
step_up at the last step of stairs failed because sub-step 1's
AdjustOffset had no ContactPlane to compute the lift direction (the
seed was load-bearing for step_up correctness).

Revert + better fix:
  1. Re-add the L622 seed (PhysicsEngine.cs:620-626).
  2. Add no-op-if-unchanged guard inside CollisionInfo.SetContactPlane
     (TransitionTypes.cs:259-279). When called with values identical
     to current state, early-return without incrementing
     ContactPlaneWriteCount or rewriting fields.

When the player stands on the same plane tick after tick, the L622
seed re-calls SetContactPlane with identical args — these now no-op
instead of inflating the counter and re-writing the same values.
Only actual state changes (e.g. landing on a new step's plane, cell
crossing) increment the counter.

Verification (post-rebuild, pre-this-commit slice 2 first attempt):
- scen3 walk produced 2,690 cp-writes (down from 30,420 = 91%
  reduction from L622-seed presence)
- BUT user could not pass the last step of stairs — step_up regression
- Test suite: 1148 + 8 pre-existing fail baseline maintained but
  physical behavior broke

Post-this-commit expectations:
- Test suite: 1148 + 8 (unchanged, no behavioral change in fixtures
  because the seed value is what the fixtures already expect)
- Stair-walking: works (seed restored)
- CP-write count: significantly reduced (most seeds are no-ops because
  body.CP doesn't change tick-over-tick on stable footing)
- Issues #96 / #97: re-test in re-capture; #96 should be largely
  closed via the guard; #97 (fall-through + stuck-in-falling) was
  observed pre-slice-2 too, so unrelated to the seed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:32:23 +02:00
Erik
892019bc9a fix(phys): A6.P3 slice 2 — remove L622 per-tick CP seed (issue #96)
Closes issue #96 (per-tick PhysicsEngine.ResolveWithTransition CP seed
contributing 99.3% of post-slice-1 CP writes). Matches retail's
CTransition::init at acclient_2013_pseudo_c.txt:271954 which explicitly
clears contact_plane_valid = 0 at transition start.

Cross-tick CP retention now flows entirely via retail-faithful
mechanisms:
  - Mechanism A: BSPQuery.FindCollisions Path-6 land write
  - Mechanism B: Transition.ValidateTransition LKCP restore (slice 1)
  - Body persist at transition end (already existed)

Cost (deliberate): AdjustOffset on sub-step 1 of each tick takes the
'no contact plane' path. Slope-snap loss is imperceptible (sub-steps
are small, sub-steps 2+ pick up CP normally).

Likely closes issue #97 (phantom collisions + fall-through) as
side-effect — hypothesis was stale-CP slope-snap from body.ContactPlane
of a previous cell. To be verified post-commit via re-capture + user
happy-test.

Verification this commit:
- Test suite: 1148 pass + 8 pre-existing fail (baseline maintained)
- scen3 re-capture pending (separate commit)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:19:48 +02:00
Erik
5f7722a3a4 fix(phys): A6.P3 slice 1 step 2 — strip indoor walkable synthesis
Closes A6.P2 Finding 2 (ContactPlane resynthesis blowup, 250x to ∞x
more CP writes than retail). Indoor branch of Transition.FindEnvCollisions
now matches retail's CEnvCell::find_env_collisions tiny shape (decomp
line 309573): call BSPTREE::find_collisions, return OK. No synthesis,
no per-frame ValidateWalkable call, no per-frame ContactPlane write.

Cross-frame CP retention now flows via:
  - Mechanism A: BSPQuery.FindCollisions Path-3 step-down write on
    grounded movers (retail-faithful: BSPTREE::step_sphere_down at
    acclient_2013_pseudo_c.txt:323711 always writes contact_plane when
    it finds a walkable surface — only fires if sphere penetrates floor).
  - Mechanism B: per-transition LKCP restore in ValidateTransition
    (added in 5aba071) for the Collided/Adjusted/Slid result cases.
  - PhysicsEngine.RunTransitionResolve body persist (unchanged).

TryFindIndoorWalkablePlane definition retained for now; deleted in
A6.P4 alongside the #90 sphere-overlap workaround.

Test fix: IndoorContactPlaneRetentionTests sphere position corrected
from 5 cm below the floor (pre-fix arrangement to trigger synthesis)
to exactly on the floor (worldPosZ = floorZ). A grounded sphere at
its natural position does not penetrate the floor polygon, so BSP
Path 5 finds no intersection and returns OK immediately — zero
additional CP writes in 60 frames. Previously the below-floor position
was causing Path 5 → StepSphereUp → DoStepDown → SetContactPlane
every frame (60 writes), not the synthesis path.

Verification:
- IndoorContactPlaneRetentionTests: PASS (was the 9th expected fail;
  back to 1148 pass + 8 pre-existing fail).
- Full suite: 1148+420 pass, 8 fail (baseline maintained +1 pass).
- Re-capture verification (scen1/3/5) deferred to Task 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:12:45 +02:00
Erik
5aba071aec feat(phys): A6.P3 slice 1 step 1 — add Mechanism B (LKCP restore)
Restores CollisionInfo.ContactPlane from LastKnownContactPlane when:
  - LKCP is valid
  - the sphere's current center is geometrically close to the LKCP
    plane (|dot(global_curr_center, N) + d| <= radius + EPSILON)

Matches retail's validate_transition LKCP-restore at
acclient_2013_pseudo_c.txt:272577 (CTransition::validate_transition,
address 0050aa70, lines 272565-272582). Slice 1 step 1 of the
A6.P3 indoor CP retention fix. Step 2 (Task 5) strips the
TryFindIndoorWalkablePlane synthesis from FindEnvCollisions.

Also fixes the proximity-check sphere: was using
sp.GlobalSphere[0].Origin (start sphere); now uses
sp.GlobalCurrCenter[0].Origin (current center) per retail
(acclient_2013_pseudo_c.txt:272568).

Tests: 1147 pass, 9 fail (8 pre-existing + 1 IndoorContactPlaneRetention
from T3 — expected; T5 lands the actual synthesis-strip fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:58:03 +02:00
Erik
869edd93b0 test(phys): A6.P3 slice 1 — add CollisionInfo.ContactPlaneWriteCount
Internal test-only counter incremented by SetContactPlane. Required
by IndoorContactPlaneRetentionTests to assert CP retention works
post-Finding-2 fix (A6.P2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:23:56 +02:00
Erik
dd95c10162 feat(ui): A6.P1 — add ProbePushBack mirror to DebugVM
Runtime checkbox mirror for ProbePushBackEnabled. Toggling in
DebugPanel (under ACDREAM_DEVTOOLS=1) flips all three [push-back]
emission sites live without relaunch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:38:51 +02:00
Erik
642734dcd0 feat(physics): A6.P1 — instrument CheckOtherCells with [push-back-cell]
Wires LogPushBackCellTransit into the multi-cell BSP iteration loop
just before ApplyOtherCellResult halts. Captures primary/other
cell ids + BSP result for direct comparison to retail's
CTransition::check_other_cells loop (already ported as A4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:38:03 +02:00
Erik
66ee757926 feat(physics): A6.P1 — add LogPushBackCellTransit helper
One-line per-iteration emission helper for the CheckOtherCells
multi-cell BSP loop. Captures primary/other cell ids, BSP result,
and halted flag for direct comparison to retail's
CTransition::check_other_cells loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:36:42 +02:00
Erik
35631d1ec0 feat(physics): A6.P1 — instrument FindCollisions with [push-back-disp]
Wires LogPushBackDispatch into the modern FindCollisions overload
at the entry block (after path/collisions/obj locals + movement
computed). Legacy overload at line ~1895 delegates to modern, so
single instrumentation site covers all dispatches.

returnState=-1 sentinel marks "entry log" — A6.P2 analysis pairs
each entry with subsequent [push-back] adjust-sphere lines and
the eventual return state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:34:55 +02:00
Erik
2d1f27d647 feat(physics): A6.P1 — add LogPushBackDispatch helper
One-line per-call emission helper for the FindCollisions dispatcher
instrumentation site. Captures path-selection state (collide flag,
insertType, objState) + walk-interp + return state for direct
comparison to retail's BSPTREE::find_collisions breakpoint.
Output uses the [push-back-disp] tag to disambiguate from
[push-back] adjust-sphere events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:32:37 +02:00
Erik
eb8a3186e7 feat(physics): A6.P1 — instrument AdjustSphereToPlane with [push-back]
Wires the LogPushBackAdjust helper into all three return paths
of AdjustSphereToPlane (early-return on no-movement, early-return
on interp out-of-window, and the applied path). Probe is gated by
ProbePushBackEnabled so it's zero-cost when off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:30:46 +02:00
Erik
3a173b9616 feat(physics): A6.P1 — add LogPushBackAdjust helper
One-line per-call emission helper for the AdjustSphereToPlane
instrumentation site. Direct field-for-field paired comparison to
retail's CPolygon::adjust_sphere_to_plane breakpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:28:20 +02:00
Erik
ad6c89de33 fix(physics): A6.P1 — drop unresolvable <see cref> to private method
BSPQuery.AdjustSphereToPlane is private; <see cref> from outside the
class can't resolve and emits CS1574. Switched to <c>...</c> code
span. Other two cross-refs (FindCollisions public, CheckOtherCells
internal-same-assembly) keep their <see cref> form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:27:00 +02:00
Erik
ace9e62213 feat(physics): A6.P1 — add ProbePushBackEnabled toggle
New PhysicsDiagnostics flag gates the [push-back] probe shipping
in subsequent tasks. Env-var ACDREAM_PROBE_PUSH_BACK=1 + DebugVM
mirror, matching the existing probe-toggle pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:24:22 +02:00
Erik
3e6f6ec858 chore(O-T7): code-review housekeeping after WB extraction
Five small post-cleanup items from T7 code review:

I1: Removed dead `datDir` parameter from WbMeshAdapter ctor (parameter
    was unused after _wbDats removal; ArgumentNullException.ThrowIfNull
    was misleading). Updated call sites in GameWindow.cs and
    WbMeshAdapterTests.cs.

I2: Updated stale GameWindow.cs comment that still described
    WbMeshAdapter as opening its own dat handles. Now reflects Phase O
    state: shared DatCollection via DatCollectionAdapter.

I3: Documented thread-safety contract on RenderStateCache (render-thread
    only — required for the mutable-static GL sentinel pattern).

M1: Added comment on IDatReaderWriter's write-path methods noting they
    are preserved for verbatim compatibility but unused in acdream.

M3: Added comment on Chorizite.Core PackageReference in Core.csproj
    explaining the previously-transitive dependency.

Also excluded SplitFormulaDivergenceTest.cs from the test build via
<Compile Remove>: this N.5b one-time data-collection test referenced
WorldBuilder.Shared types directly; after Phase O-T7 dropped that
project reference it no longer compiles. The sweep data it produced
already informed the N.5b Path-C decision and the file is retained
in the tree for historical reference.

Build green; tests green (1146 + 8 pre-existing failures baseline
maintained).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:29:06 +02:00
Erik
dc722e70bd feat(O-T7): drop WB project references; complete extraction
End of Phase O extraction. Final cleanup:

- Dropped <ProjectReference> entries to WorldBuilder.Shared and
  Chorizite.OpenGLSDLBackend from both AcDream.App.csproj and
  AcDream.Core.csproj.
- Added Chorizite.Core NuGet PackageReference to AcDream.Core.csproj
  (needed by Core.Rendering.Wb.TextureHelpers for TextureFormat enum;
  previously transitive through the WB project ref).
- Added BCnEncoder.Net.ImageSharp (1.1.2) + SixLabors.ImageSharp (3.1.12)
  as direct PackageReferences to AcDream.App.csproj — previously transitive
  via Chorizite.OpenGLSDLBackend project; used directly by ObjectMeshManager.

Item A (BaseObjectRenderManager static fields):
- Inlined CurrentAtlas/CurrentVAO/CurrentIBO into a new RenderStateCache.cs
  static class (AcDream.App.Rendering.Wb namespace) — the 4 consumers
  (ManagedGLIndexBuffer, ManagedGLTexture, ManagedGLTextureArray, ParticleBatcher)
  all reference RenderStateCache.* instead of BaseObjectRenderManager.*.
- Dropped using Chorizite.OpenGLSDLBackend.Lib from all 4 consumers and from
  WbDrawDispatcher (which had it only as a dead import).

Item B (ActiveParticleEmitter.ObjectLandblock):
- ObjectLandblock? erased to object?; WorldBuilder.Shared.Models.ObjectId? erased
  to ulong? — both fields are stored but never read by any consumer in our codebase.
- Dropped both WB using directives from ActiveParticleEmitter.cs.

Item C (IDatReaderWriter / IDatDatabase):
- Verbatim copy of both interfaces into IDatReaderWriter.cs in
  AcDream.App.Rendering.Wb namespace — DatCollectionAdapter and ObjectMeshManager
  already live in that namespace, so no using changes needed.
- Dropped using WorldBuilder.Shared.Services from DatCollectionAdapter.cs and
  ObjectMeshManager.cs.

Additional extractions required by the reference drop:
- GeometryUtils.cs: verbatim copy of WorldBuilder.Shared.Lib.GeometryUtils
  (float-precision overloads only; Vector3d double-precision overloads omitted —
  ObjectMeshManager uses only the float versions).
- Dropped using WorldBuilder.Shared.Lib from ObjectMeshManager.cs.

WbMeshAdapter.cs cleanup (spec O-D12):
- Deleted _wbDats (DefaultDatReaderWriter) field + ctor init + Dispose call.
- Deleted the [indoor-upload] NULL_RESULT diagnostic block (lines ~205-262) —
  its Phase 2 cell-resolution investigation is complete; its _wbDats.ResolveId
  dependency goes with this commit.
- Deleted _pendingEnvCellRequests field + isPendingEnvCell tracking in Tick().
- Simplified Tick() to a clean drain loop.

Deleted SplitFormulaDivergenceTest.cs — one-time N.5b data-collection sweep;
job done.

Verified acceptance criteria:
- Zero <ProjectReference> to WorldBuilder.* / Chorizite.OpenGLSDLBackend.* in any csproj.
- Zero 'using WorldBuilder.*' / 'using Chorizite.OpenGLSDLBackend.*' in src/.
- DefaultDatReaderWriter referenced in zero places in src/ (comments only).

Build green (0 warnings, 0 errors).
Tests: 1154 total (-1 from deleted SplitFormulaDivergenceTest), 1146 pass,
8 pre-existing failures (unchanged from baseline — physics/input tests
unrelated to this change).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:17:33 +02:00
Erik
a9ccc5acf5 fix(O-T4): thread-safety lock in DatDatabaseWrapper + drop unused using
Code-review findings on T4:

1. Added lock(_lock) around _db.TryGet and TryGetFileBytes in
   DatDatabaseWrapper, matching WB's DefaultDatDatabase pattern.
   ObjectMeshManager.PrepareMeshDataAsync runs on the thread pool, so
   concurrent dat access through the adapter must be serialized — our
   underlying DatCollection is not documented as thread-safe.

2. Removed unused `using WorldBuilder.Shared.Models;` from WbMeshAdapter.cs
   (its only purpose was TerrainEntry, which moved to AcDream.Core in T2).

Build green; tests green (1147 passing, 8 pre-existing failures baseline).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:43 +02:00
Erik
c0326523ac fix(O-T4): address spec-review findings — InstanceData + using cleanups
Four fixes from T4 spec review:

1. Extracted InstanceData.cs (14-line struct) verbatim to
   src/AcDream.App/Rendering/Wb/InstanceData.cs (per O-D1).

2. ObjectMeshManager.cs: replaced `using Chorizite.OpenGLSDLBackend.Lib;`
   with `using AcDream.Core.Rendering.Wb;` (TextureHelpers comes from
   our T2 Core extraction; InstanceData comes from new T4 cleanup).

3. EmbeddedResourceReader.GetEmbeddedResource promoted from `internal`
   to `public` per O-D9 intent (the type promotion only changed the
   class signature in T3; this finishes the spec).

4. OpenGLGraphicsDevice.cs: removed stale T3 interim comment at
   lines 142-145 — T4 resolved the ParticleBatcher construction
   via post-ctor assignment in WbMeshAdapter.cs:78.

Build green; tests green (1147 passing, 8 pre-existing failures
baseline maintained).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:50:05 +02:00
Erik
d16d8cd4e5 feat(O-T4): extract ObjectMeshManager + mesh pipeline closure into AcDream.App.Rendering.Wb
Phase O Task 4: extract the WB mesh pipeline (ObjectMeshManager + 7 support files)
from references/WorldBuilder into src/AcDream.App/Rendering/Wb/ and bridge dat I/O
through our DatCollection via a thin DatCollectionAdapter.

O-D7 adapter path taken: ObjectMeshManager has 26 _dats.X call sites (threshold 20),
so a DatCollectionAdapter : IDatReaderWriter is introduced rather than refactoring
ObjectMeshManager's internal dat access directly.

Files added (verbatim copies, namespace-only changes):
- ObjectMeshManager.cs — mesh pipeline hub; IDatReaderWriter field satisfied by adapter
- GlobalMeshBuffer.cs — single global VAO/VBO/IBO manager
- EdgeLineBuilder.cs — wireframe edge geometry from CellStruct polygons
- ModernRenderData.cs — ModernBatchData + LandblockMdiCommand structs
- TextureAtlasManager.cs — texture array grouping by (Width, Height, Format)
- ParticleBatcher.cs — GPU particle batching; T4 interim uses BaseObjectRenderManager
  static fields from Chorizite.OpenGLSDLBackend.Lib (stays until T7)
- ParticleEmitterRenderer.cs — per-emitter particle lifecycle + rendering
- ActiveParticleEmitter.cs — wrapper holding renderer + part index + local offset
- DatCollectionAdapter.cs — NEW: bridges DatCollection → IDatReaderWriter; implements
  ResolveId() via DatDatabase.TypeFromId + Tree.TryGetFile in HighRes→Portal→Language→Cell
  order matching DefaultDatReaderWriter; DatDatabaseWrapper wraps DatDatabase as IDatDatabase

WbMeshAdapter.cs changes (T4 Step 6):
- _graphicsDevice switched from Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice to
  extracted AcDream.App.Rendering.Wb.OpenGLGraphicsDevice
- ParticleBatcher = new ParticleBatcher(_graphicsDevice) restored (T3 had null! placeholder)
- ObjectMeshManager now constructed with new DatCollectionAdapter(dats) instead of _wbDats
- _wbDats field + its construction + disposal + [indoor-upload] NULL_RESULT diagnostic block
  left intact — T7 cleanup removes these once WorldBuilder project ref is dropped

EmbeddedResourceReader.cs: replaced assembly manifest lookup (wrong prefix for our assembly)
with disk-based lookup mapping "Shaders.Particle.vert" → Rendering/Shaders/wb_particle.vert;
consistent with all other acdream shaders.

wb_particle.vert / wb_particle.frag: WB particle shaders copied verbatim with wb_ prefix
to distinguish from acdream's own particle.vert.

OpenGLGraphicsDevice.cs: ParticleBatcher property type updated to extracted ParticleBatcher;
setter changed from private to internal so WbMeshAdapter (same assembly) can assign post-ctor.

Build: green (0 errors, 0 warnings in AcDream.App).
Tests: 1147+8 baseline maintained (8 pre-existing failures unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:37:55 +02:00
Erik
4cc38805b5 feat(O-T3): extract GL infrastructure to AcDream.App
Phase O Task 3 — verbatim-copy GL infra from Chorizite.OpenGLSDLBackend
into src/AcDream.App/Rendering/Wb/ (namespace AcDream.App.Rendering.Wb).

18 files extracted (all namespace-changed; no algorithm changes):
  OpenGLGraphicsDevice, ManagedGLTexture, ManagedGLTextureArray,
  ManagedGLVertexBuffer, ManagedGLIndexBuffer, ManagedGLVertexArray,
  ManagedGLFrameBuffer, ManagedGLUniformBuffer, GLSLShader, GLHelpers,
  GLStateScope, GpuMemoryTracker, SceneData, DebugRenderSettings,
  TextureParameters, TextureFormatExtensions, BufferUsageExtensions,
  EmbeddedResourceReader.

3 internals promoted to public (O-D9):
  EmbeddedResourceReader, TextureFormatExtensions, BufferUsageExtensions.

SixLabors.ImageSharp not reachable: TextureHelpers was placed in
AcDream.Core (no GL/ImageSharp dep); only the GL types went to App.

TextureHelpers.GetCompressedLayerSize added to AcDream.Core.Rendering.Wb
(was in Chorizite.OpenGLSDLBackend.Lib.TextureHelpers; uses
Chorizite.Core.Render.Enums.TextureFormat which Core gets transitively
via the still-present WB project refs).

T3/T4 boundary interims:
  - WbMeshAdapter._graphicsDevice stays Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice
    (T4 will swap it when ObjectMeshManager is extracted).
  - OpenGLGraphicsDevice.ParticleBatcher deferred to null! (T4 extracts
    ParticleBatcher alongside ObjectMeshManager; can't pass `this` of our
    new type to the WB-original ctor before T4).
  - ManagedGLTextureArray uses our TextureHelpers via explicit alias.
  - IUniformBuffer is in Chorizite.Core.dll under Chorizite.OpenGLSDLBackend
    namespace (unusual packaging); resolved via type alias.
  - AcDream.App.csproj gets explicit Chorizite.Core 0.0.18 PackageReference
    (IUniformBuffer + other Chorizite.Core types now used directly in App).

Build green. Test baseline 1147+8 maintained (1902 passing, 8 pre-existing
MotionInterpreterTests failures unrelated to T3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:00:31 +02:00
Erik
16bc10c99d feat(O-T2): extract pure stateless helpers to AcDream.Core.Rendering.Wb
Verbatim copy of 5 WorldBuilder files into src/AcDream.Core/Rendering/Wb/:
- TextureHelpers.cs (pixel-format decoders, Chorizite Lib)
- SceneryHelpers.cs (scenery transforms, Chorizite Lib)
- TerrainUtils.cs, TerrainEntry.cs, CellSplitDirection.cs (WB.Shared Landscape)

Namespace migrated from WorldBuilder.* / Chorizite.OpenGLSDLBackend.Lib
to AcDream.Core.Rendering.Wb per O-D11. [MemoryPackable] stripped from
TerrainEntry per O-D10 (we don't serialize the struct).

Updated 3 source files + 1 test file to import from the new namespace.

Verbatim discipline (O-D1): only namespace + MemoryPack attribute changed.
All algorithm bodies byte-identical to upstream.

Note: TextureHelpers omits IsAlphaFormat() and GetCompressedLayerSize()
because those reference Chorizite.Core.Render.Enums.TextureFormat, a type
that has no path into AcDream.Core without adding an unwanted NuGet dep.
Neither method is called from Core or the test suite; the omission is safe.

Verified on main checkout: dotnet build green (0 errors), dotnet test
green — Failed: 8, Passed: 1147, Skipped: 0, Total: 1155 (baseline maintained).
TextureDecodeConformanceTests (9/9) pass byte-for-byte after namespace swap.
AcDream.Core project alone builds green in this worktree (App-layer failures
are pre-existing, blocked by empty WB submodule, addressed in Tasks 3+4).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:13:26 +02:00
Erik
8c073e0c4c chore(O-T1): create Core/Rendering/Wb directory + NOTICE.md attribution
Phase O setup: extracted-WB code home + MIT attribution per O-D5.
Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:59:56 +02:00
Erik
23ab17362a fix(physics): #92 — seed resolver with server cell id at player-mode entry
EnterPlayerModeNow computed the initial cellId from landblock prefix
+ hardcoded low byte 0x0001 (outdoor sentinel) and passed only the
low 16 bits to PhysicsEngine.Resolve. When the server places the
player INSIDE a building (spawn cell id e.g. 0xA9B4015A indoor), the
sentinel forced the outdoor seed branch — for the first several ticks
CheckBuildingTransit hadn't yet picked up the interior cell (it
depends on the sphere overlapping the destination cell's BSP), the
player was classified outdoor, indoor BSP queries didn't run, and
exterior walls were passable until enough inward motion finally
promoted them.

User-visible symptom: "logged in inside the inn, ran out through the
exterior wall; ran back in and the walls now block."

Fix: use spawn.Position.LandblockId (the server's authoritative full
cell id with landblock prefix) when available; fall back to the old
sentinel only if the spawn record is missing (defensive — shouldn't
fire in live play since OnLiveEntitySpawnedLocked writes _lastSpawnByGuid
before EnterPlayerModeNow can possibly run).

1147 + 8 baseline maintained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:17:37 +02:00
Erik
7ac8f544a7 fix(physics): #89 — sphere-overlap in CheckBuildingTransit closes login-inside-inn classification race
Outdoor→indoor entry path used PointInsideCellBsp (point-only) for the
building-portal containment test. When the player logs in INSIDE a
building and the foot-sphere center is just past the destination cell's
CellBSP boundary, the point-only check failed → CellId stuck as
outdoor → indoor BSP queries never ran → walls passable. User-reported
symptom: "logged in in the inn, at start ran through exterior walls,
ran back in and they block now."

Fix: swap PointInsideCellBsp for SphereIntersectsCellBsp (the radius-
aware port from #90). Promotes CellId to the interior cell the moment
ANY part of the foot-sphere crosses the destination cell boundary —
matches retail's CCellStruct::sphere_intersects_cell timing at
acclient_2013_pseudo_c.txt:317666 exactly.

The sphereRadius parameter was already plumbed through CheckBuildingTransit
per #89's documented "future upgrade" note from 2026-05-19 (which is
exactly today's symptom). Closes #89.

1147 + 8 baseline maintained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:12:30 +02:00
Erik
c0d84057cb fix(physics): #91 — query indoor cell shadows in FindObjCollisions
Interior items (fireplaces, tables, chests) registered via A1.5's
ShadowObjectRegistry.Register `cellScope` parameter (commit 4d3bf6f)
are stored under their ParentCellId key (e.g. 0xA9B40121). But
GetNearbyObjects's broad-phase only iterates outdoor 24m landcell
keys (0xA9B40029 etc) and never looks up indoor cell keys, so
interior shadows were registered but unreachable. User-visible
symptom: tables/boxes/fireplaces don't block movement, while walls
DO block (the indoor BSP path is separate).

Fix: GetNearbyObjects accepts an optional indoorCellIds parameter
and additionally queries _cells[indoorCellId] for each entry with
low-byte >= 0x0100u. FindObjCollisions computes the set via
CellTransit.FindCellSet (same set A4 uses for multi-cell BSP
iteration) and passes it through. Outdoor seeds typically produce
sets containing only outdoor land-cells which the new branch filters
out, so the outdoor-only behavior is preserved.

1147 + 8 baseline maintained. Closes the user-reported regression
"walls block now correct but interior items such as tables and boxes
or fireplaces do not block."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:03:51 +02:00
Erik
4ca35966f8 fix(physics): #90 — sphere-overlap cell stickiness at doorway threshold
ResolveCellId's indoor-seed fall-through was point-only: when the indoor
BSP push-back moved the foot-sphere CENTER a few cm outside the indoor
CellBSP volume, the resolver flipped CellId back to outdoor. Next tick
re-promoted via CheckBuildingTransit. The ping-pong caused most ticks
to be classified outdoor, bypassing indoor BSP wall checks entirely
and producing the user-reported "walls walk through everywhere in the
inn" symptom.

Fix: port retail's BSPTREE::sphere_intersects_cell_bsp
(acclient_2013_pseudo_c.txt:323267 → BSPNODE variant at :325546) as
BSPQuery.SphereIntersectsCellBsp(node, center, radius). Replace the
point-only check at PhysicsEngine.ResolveCellId:285 with the radius-
aware overlap test. Player stays classified indoor as long as ANY
part of the foot-sphere still overlaps the indoor cell volume; only
flips to outdoor when the sphere is FULLY outside.

Retail uses a 0.01 m epsilon on the radius (acclient :325551); ported
verbatim. 8 new unit tests cover null/leaf/inside/on-plane/straddling/
fully-outside/tangent-boundary cases plus a regression-anchor test
that proves the old PointInsideCellBsp would have returned false for
the same straddling input.

1147 + 8 baseline maintained (was 1139 + 8 before #90 fix). Closes #90.
A4 multi-cell iteration (shipped earlier today) should now actually
exercise in production since the player can stably remain in indoor
cells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:30:36 +02:00
Erik
691493e579 Reapply "feat(physics): A4 — wire CheckOtherCells into FindEnvCollisions"
This reverts commit 3add110449.
2026-05-20 20:06:14 +02:00
Erik
3add110449 Revert "feat(physics): A4 — wire CheckOtherCells into FindEnvCollisions"
This reverts commit 967d065141.
2026-05-20 18:50:26 +02:00
Erik
967d065141 feat(physics): A4 — wire CheckOtherCells into FindEnvCollisions
After the primary cell's BSP returns OK, query every other cell the
foot-sphere overlaps via CellTransit.FindCellSet + Transition.CheckOtherCells.
Closes the Holtburg inn vestibule wall walk-through: the vestibule
(cell 0xA9B40164) has only 4 BSP polys; walls live in the adjacent
interior cell (0xA9B40157). Without A4 the adjacent cell's BSP was
never queried.

End-to-end test reduces the real Holtburg bug to a minimal synthetic
two-cell fixture: empty vestibule BSP + interior cell with the
existing BSPStepUpFixtures.TallWall (the same fixture B2 uses to
prove a grounded mover can't scale a 5m wall). Pre-A4: returns OK
(walks through). Post-A4: returns Slid (the wall halts the
transition).

FindEnvCollisions visibility tightened from private → internal so
the integration test can call it directly without going through
FindTransitionalPosition's sub-step iteration.

Retail oracle: acclient_2013_pseudo_c.txt:272717-272798
(CTransition::check_other_cells).

Spec: docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md
Plan: docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:23:00 +02:00
Erik
493c5e5ff6 feat(physics): A4 — Transition.CheckOtherCells + ApplyOtherCellResult
Port of retail's CTransition::check_other_cells at
acclient_2013_pseudo_c.txt:272717-272798. Iterates every non-primary
cell in a candidate set, runs BSPQuery.FindCollisions per cell with
that cell's WorldTransform-derived rotation + origin, halts on first
Collided/Adjusted/Slid.

ApplyOtherCellResult is the combine-semantics helper extracted for
unit testability — it pins the retail switch:
  - Collided/Adjusted → CollidedWithEnvironment = true (gated on
    !Contact), halt.
  - Slid              → ContactPlaneValid + ContactPlaneIsWater = false,
                        halt.
  - OK                → continue.

Not yet wired into FindEnvCollisions — see next commit. Probe gated
on PhysicsDiagnostics.ProbeIndoorBspEnabled (ACDREAM_PROBE_INDOOR_BSP).

Six new unit tests: five against the pure combine helper for each halt
case + one direct CheckOtherCells call exercising the null-BSP guard.

Spec: docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md
Plan: docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md

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