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>
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>
The previous version of LiveCompare_FirstCap_HeadHitsCottageFloor
asserted the harness matched the live cap by per-field diff, which
correctly FAILED with a clear divergence message. Converted it to the
documents-the-bug pattern matching the existing
Harness_Finding_SphereGoesAirborneAtTick1 style: passes WHILE the
harness lacks the cottage GfxObj, and will start failing when the
cottage GfxObj is added — at which point the test should be flipped
to AssertCallMatchesCapture(engine, captured).
Test name now reads as a finding:
LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered
Second-capture poly-dump finding (committed in the test's xmldoc): the
live cap event attributes the blocking entity as obj=0xA9B47900 — a
landblock-baked static building (the cottage GfxObj). The cottage's
floor lives in this GfxObj's polygon table as a ShadowEntry, NOT in
any of the cottage's cells. The harness's BuildEngineWithCellarFixtures
intentionally skips RegisterStairRampGfxObj today, so the cottage
floor (downward-facing polygon at world Z=94.0) isn't present — and
the harness doesn't reproduce the cn=(0,0,-1) cap.
Next-session move: extract the cottage GfxObj's full polygon list
from a focused live capture (set ACDREAM_PROBE_BUILDING=1 so the
[resolve-bldg] probe fires per-polygon during the cap), add it to
RegisterStairRampGfxObj (rename to RegisterCottageGfxObj), uncomment
the registration call. The harness should then reproduce live's
cn=(0,0,-1) — at which point the documents-the-bug test starts
failing and should be flipped to the assertion form.
Test baseline maintained: 1178 + 8 pre-existing failures (was
1172 + 8 pre-changes; added 6 tests, all pass under serial run).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The capture apparatus pays off on the FIRST iteration. Three records
sampled from a live cottage-cellar session — tick 0 (spawn at Z=92.53),
tick 376 (player on the cellar ramp at Z=91.49), and tick 1183 (first
cap event, foot Z=92.74 with cn=(0,0,-1)) — replayed against the
harness engine reveal:
- LiveCompare_Tick0_Spawn: PASSES (full round-trip).
- LiveCompare_Tick376_OnRamp: PASSES (ramp walkable
polygon hydrates correctly,
engine reproduces live).
- LiveCompare_FirstCap_HeadHitsCottageFloor: FAILS by exactly the
divergence shape that names
the missing fixture.
The cap-record divergence:
Result.Position: live=(141.3865,7.2243,92.7390)
harness=(141.3599,7.2243,92.7390) (Y slid; X stuck)
Result.CollisionNormal:live=(0,0,-1) ← downward = cottage floor from below
harness=(0,0,+1) ← upward = some other floor
Plus the LiveCompare_FirstCap_DiagnosticDump test (always passes; it's
a probe-firing scratch test) prints every cell polygon in world frame:
Cellar 0xA9B40147 — ceiling polys at world Z=93.80 cover X=133-142,
Y=-1.0-11.5 but NOT the sphere XY of (141.39, 7.03) — at the right
edge of Y=7.03 the ceiling quads are at Y<3.90 or Y>8.70.
Cottage 0xA9B40143 — floor polys at world Z=94.0 cover X=136.7-140.5,
Y=3.9-13.1 but NOT (141.39, 7.03) either — at X=141.39 we are 0.89m
east of the floor quad's rightmost vertex.
Cottage 0xA9B40146 — only 4 walls, no floor.
So both cells we have CAN'T produce the live's cn=(0,0,-1). The actual
blocking polygon must be in a cell or static object we haven't loaded
into the harness yet. The cellar is rectangularly bounded; the cottage
above has a floor that spans the cottage, but the floor polygon RIGHT
ABOVE the ramp top (which is where the freeze fires) is in some OTHER
cell — either a separate cottage-floor sub-cell or a building static
GfxObj.
This is the first evidence-driven step in the saga. Six sessions of
speculation produced ten failed fix shapes; the apparatus produced
this finding in one round trip. Next step: re-capture with
ACDREAM_PROBE_POLY_DUMP + ACDREAM_DUMP_CELLS covering 0xA9B40140-
0xA9B4014F to identify the missing fixture cell.
Adds:
- LiveCompare_Tick0_Spawn / Tick376_OnRamp / FirstCap_HeadHitsCottageFloor
- LiveCompare_FirstCap_DiagnosticDump (always passes; dumps cell polys
in world frame + enables every relevant probe so the captured stdout
shows the harness BSP query path)
- tests/AcDream.Core.Tests/Fixtures/issue98/live-capture.jsonl
(3 representative records from the 41,228-record live capture)
- AssertCallMatchesCapture helper: per-field diff with Vector3 / float
tolerances, reports every divergence not just the first.
Test baseline maintained at 1172 + 8 baseline + 5 new tests pass +
1 known-failing test that pinpoints the bug = 1178 + 9 (where the
new failure is the desired evidence-driven test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Multi-step investigation of the airborne-at-tick-1 bug per the
systematic-debugging skill. Several hypotheses tested via the
harness, each producing the same (0,1,0) hit normal at tick 1:
1. WalkablePolygon seeding ADDED to BuildInitialBody (was missing).
PhysicsEngine.cs:665-673 requires body.WalkablePolygonValid +
WalkableVertices to call SpherePath.SetWalkable. With seeded
walkable poly: walkPoly=True survives tick 1 (was False before).
BUT engine still reports hit=(0,1,0) and body goes airborne.
2. Initial Z lift removed (back to 0): same airborne behavior.
3. Synthetic stair GfxObj DISABLED: same (0,1,0) hit. Hit is not
from FindObjCollisions.
4. Stub landblock REMOVED: same (0,1,0). FindObjCollisions early-
returns without landblock context, FindEnvCollisions's outdoor
terrain returns null. Hit is not from terrain.
5. SYNTHETIC BSP attached to cell fixtures (Hydrate sets BSP=null
per its xmldoc; without BSP the indoor branch is skipped, falls
through to outdoor terrain). One-leaf BSP referencing every poly
in cell.Resolved. Indoor BSP path now runs. Same (0,1,0) hit.
Trace timeline at tick 1:
find-start: walkPoly=True, CP valid, oi=0x303 (Contact+OnWalkable)
after-adjust: req=(0,-0.1,0) adj=(0,-0.1,0) — no projection change
before-insert: check=(141.5, 9.4, 91.43)
stepdown-enter (Contact-recovery): stepDown=True, height=0.04
stepdown-after-offset: check=(141.5, 9.4, 91.39) — moved DOWN 0.04
stepdown-after-insert: state=OK, cp=n/a (no walkable found)
stepdown-reject
(second stepdown attempt — same outcome)
after-insert: state=Collided, hit=n/a, walkPoly=False
after-validate: state=OK, hit=(0,1,0), slide=(0,1,0)
oi=0x300 (Contact+OnWalkable CLEARED)
The (0,1,0) hit is set by ValidateTransition between after-insert
and after-validate. ValidateTransition's default-push-up code path
sets UnitZ=(0,0,1), NOT UnitY=(0,1,0). So something INSIDE
TransitionalInsert sets ci.CollisionNormal=(0,1,0) before
ValidateTransition runs (12 SetCollisionNormal call sites in
TransitionTypes.cs — root cause not isolated to one).
Per systematic-debugging skill: 5+ hypotheses tested without
convergence = "question architecture". The bug is hidden deeper
than a single misconfigured init field.
Next session pickup: build a side-by-side instrumentation harness
that mimics PlayerMovementController's EXACT call sequence
(PhysicsBody field state, ResolveWithTransition args, frame
ordering) and compare per-tick divergence against a live capture.
The harness is missing some piece of state production carries
across ticks — find what piece.
Apparatus progress (committed):
- Harness with synthetic stair GfxObj registration (Issue #98 ramp polygon now constructable programmatically)
- Synthetic cell-BSP attachment (AttachSyntheticBsp) — unlocks indoor
BSP collision path for hydrated cell fixtures
- WalkablePolygon seeding in BuildInitialBody (PhysicsBody seeding pattern documented)
- Three diagnostic dump tests for tick-by-tick traces
Test baseline: 1167 + 5 (harness) = 1172 + 8 pre-existing failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two diagnostic-only tests:
- Harness_DiagnosticDump_FirstTenTicks: prints trajectory + resolve
probe lines for the seeded-body path
- Harness_DiagnosticDump_NoBodySeed: same but with body=null, isolating
whether the CP seed contributes to the airborne-at-tick-1 issue
Also adjusts InitialSphereWorld to lift the sphere by 0.05m above
cellar floor (sphere bottom at Z=91.00, not Z=90.95). The lift
should give the engine a clean step-down on tick 1 instead of an
exact-boundary contact.
Experimental finding: NEITHER the no-body-seed path NOR the 0.05m
lift changes the airborne-at-tick-1 behavior. With sphere center
at world Z=91.48 (0.05m + radius above cellar floor at 90.95):
- Tick 1: in=(141.5, 9.5, 91.48), out=(141.5, 9.5, 91.48) — Y move
rejected. hit=yes n=(0,0,1) walkable=False.
- Tick 2+: Y advances by 0.1/tick, Z stays put, onGround stays False.
The hit normal (0,0,1) at tick 1 means the engine treats the cellar
floor polygon as a NON-WALKABLE collision target when the sphere is
seeded grounded above it. The walkability classifier returns False
even though Normal.Z=1.0 > FloorZ=0.6642. This is a real engine bug
worth investigating in a future session — independent of the cellar-up
freeze.
The synthetic ramp polygon registered via RegisterStairRampGfxObj is
NOT reached because the sphere is now airborne and floats over the
cellar floor without contacting the ramp.
Next session pickup options:
1. Debug the airborne-at-tick-1 issue (likely in TransitionTypes
FindEnvCollisions indoor BSP path — why does a flat (0,0,1) hit
return walkable=False?). Once fixed, the harness should reproduce
cellar-up freeze.
2. Pivot to a different M1.5 issue with cleaner reproduction.
3. Use the harness mechanics elsewhere — the synthetic-GfxObj +
ShadowEntry pattern is reusable for any indoor-static-collision
test (corpse pickup boundaries, door swings, etc.).
Test baseline: 1167 + 5 (harness) = 1172 + 8 pre-existing failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the trajectory replay harness with a programmatic mini-stair
piece, reconstructed from the live capture's polydump data
(docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump).
NEW finding: the cellar ramp polygon is NOT in cellStruct.PhysicsPolygons.
It lives in a separate GfxObjPhysics (the cellar's stair-piece static
building) registered via ShadowObjectRegistry, queried via
FindObjCollisions → engine.DataCache.GetGfxObj. CellDumpSerializer is
CORRECT — it captures the cell's physics polygons accurately. The
ramp polygon comes from a different data source entirely.
The polydump probe at BSPQuery.AdjustSphereToPlane:402 reports
"cell=0xA9B40147 polyId=0x0008 sides=Landblock" because the SPHERE
is in that cell at hit time — but the polygon's actual source is the
building's GfxObj. Inside the cellar fixture, polyId=0x0008 happens
to be a wall (Normal=(1,0,0)); inside the building's GfxObj, polyId
=0x0008 is the ramp (Normal=(0,-0.719,0.695) local). Same ID, different
collection.
The new RegisterStairRampGfxObj() in the harness constructs the
building's ramp polygon in WORLD coordinates (translated from
local building frame + 180° yaw), wraps it in a minimal one-leaf
PhysicsBSPTree, registers via cache.RegisterGfxObjForTest, and
attaches a ShadowEntry with cellScope=CellarId so the shadow is only
queried when the sphere is in the cellar cell (matches retail's
per-cell shadow scoping for interior statics — Issue #91 family).
Verified: world plane n=(0,0.719,0.695), d=-69.5035 (matches live
cdb capture exactly to 4 sig figs). Ramp foot at world Y=8.745,
Z=90.955; ramp top at world Y=5.845, Z=93.955. 3.0 m vertical rise.
NEW blocker discovered: the sphere goes airborne at tick 1 (same
issue documented in the prior commit's Finding #2). Sphere FLOATS
at Z=91.43 over the cellar floor, never contacts the synthetic
ramp. The synthetic stair registration mechanics are validated (the
GfxObj is in the cache, the ShadowEntry is in the registry, the
BSP tree is well-formed) — but trajectory replay still blocked on
the seeded-grounded-state bug. Next session needs to diagnose
WHY the engine reports "hit=yes n=(0,0,1) walkable=False" on tick 1
for a sphere correctly seeded as grounded on the cellar floor.
Test baseline maintained: 1167 + 4 (harness) = 1171 + 8 pre-existing
failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs:
a deterministic harness that drives PhysicsEngine.ResolveWithTransition
through N ticks against pre-loaded cell fixtures, capturing per-tick
trajectory points. Pure indoor (no landblock registration needed),
runs 200 ticks in under 100 ms.
The harness MECHANICS work — engine constructs cleanly, DataCache
accepts test fixtures via RegisterCellStructForTest, PhysicsBody
carries ContactPlane state across ticks. 4/4 tests pass, baseline
maintained (1167 + 4 = 1171 + 8 pre-existing failures).
Two real findings surfaced during commissioning, both documented as
passing tests so they don't regress silently:
Finding 1 (Harness_FixtureLimitation_NoRampPolygon): the three
issue-#98 cell fixtures contain ONLY axis-aligned polygons. The
cellar fixture (0xA9B40147) has 37 polys: 8 floor (N=(0,0,1)), 7
ceiling (N=(0,0,-1)), 22 walls. The live capture's CELLAR RAMP
polygon (N ≈ (0, ±0.719, 0.695)) is NOT in any fixture. With no
ramp polygon, the harness can't reproduce the cellar-up climb —
the sphere would walk horizontally across the cellar floor without
ever encountering a slope. Re-capture needed; investigate whether
CellDumpSerializer is skipping polygons or whether the ramp lives
in a cell we didn't dump.
Finding 2 (Harness_Finding_SphereGoesAirborneAtTick1): at the
seeded grounded initial position (sphere center 0.48 m above cellar
floor, ContactPlane = (0,0,1,-90.95), OnWalkable bit set), the
engine reports `hit=yes n=(0,0,1) walkable=False` on tick 1 and
the body's IsOnGround flips to false. Subsequent ticks proceed as
airborne (Y advances, Z stays put — no gravity in the input offset).
Unclear whether this is an engine bug (floor contact classified
as non-walkable collision) or a fixture issue (cellar floor
polygon's containment test mis-firing at the seeded XY). Either
way, the harness now exposes it deterministically.
Net value of this commit: the harness CODE is ready. Once the
fixture issue is solved, fix attempts on #98 (or any trajectory-
dependent bug) iterate in <100 ms instead of 5-minute live-launch
cycles. The "why is this so hard" point #4 from the session-pause
handoff is addressed for everything except the missing-ramp gap.
Test baseline: 1171 (1167 + 4 new) + 8 pre-existing failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>