From 82045805fdcd1b1b5503092ec0ad1a9cf0f46ce9 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 08:52:56 +0200 Subject: [PATCH 001/172] =?UTF-8?q?docs(p2):=20session=20wrap=20=E2=80=94?= =?UTF-8?q?=20P1=20done,=20P2=20(Path=205=20step-up)=20localized;=20handof?= =?UTF-8?q?f=20+=20plan/CLAUDE.md=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 membership is DONE (proven to already match retail; the 0/11 was a cdb capture artifact; merged + pushed). P2 root cause localized to BSP Path 5 grounded step-up: the Path 5 wrappers (DoStepUp=retail step_up, DoStepDown=retail step_down) are verified faithful + reached; the divergence is in the step-up CLIMB (find_walkable/step_sphere_down up-adjust when sp.StepUp=true). - docs/research/2026-06-03-p2-door-stepup-handoff.md: canonical P2 pickup + fresh-session prompt + DO-NOT-RETRY (the wrappers) + the tooling note (xunit swallows Console.WriteLine). - master-plan §3: P1 marked DONE + the (a)-(d) deletes/unifications re-scoped to approval-gated refactors of working code; P2 localization recorded. - CLAUDE.md M1.5: dated 2026-06-03 pointer (P1 done, P2 active, render seam in P3/P4, pickup doc). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 24 ++- .../2026-06-03-p2-door-stepup-handoff.md | 171 ++++++++++++++++++ ...batim-spatial-pipeline-port-master-plan.md | 28 ++- 3 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 docs/research/2026-06-03-p2-door-stepup-handoff.md diff --git a/CLAUDE.md b/CLAUDE.md index 965948f6..590cf0f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -761,7 +761,29 @@ Apparatus `ACDREAM_PROBE_FLAP` + `tools/A8CellAudit` are committed + ready. Do N H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven. **Currently working toward: M1.5 — Indoor world feels right** (resumed -from 2026-05-20 baseline after Phase O ship). **A6.P1 + A6.P2 + A6.P3 +from 2026-05-20 baseline after Phase O ship). + +**2026-06-03 — P1 membership DONE + P2 active (read this first).** The verbatim spatial-pipeline +port (master plan +[`docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md`](docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md)) +is the active effort. **P1 (membership) = DONE** — proven to ALREADY match retail; the believed +doorway "0/11 lag" was a cdb CAPTURE ARTIFACT (`change_cell` logs `m_position` before `set_frame` +writes it). Aligned re-capture → production gate 9/9 with NO code change; live `[cell-transit]` clean. +Merged + pushed to both remotes (HEAD `f0d37d8`). **P2 (door/building-shell collision) = IN PROGRESS, +root cause LOCALIZED** to **BSP Path 5 grounded step-up** — the Path 5 wrappers (`DoStepUp`=retail +`step_up`, `DoStepDown`=retail `step_down`) are verified faithful; the divergence is in the step-up +CLIMB (`find_walkable`/`step_sphere_down` up-adjust). The 5 failing Core tests are P2's target. The +visible doorway seam has moved from physics into the RENDER path (P3 camera-collision + P4 PView seal). +The (a)–(d) membership cleanups (remove `ResolveCellId`, unify `find_env_collisions`, intrinsic +building stabs, per-cell collision graph) are **approval-gated refactors of WORKING code**. **CANONICAL +PICKUP:** +[`docs/research/2026-06-03-p2-door-stepup-handoff.md`](docs/research/2026-06-03-p2-door-stepup-handoff.md) +(+ the P1 RESOLVED banner in +[`docs/research/2026-06-03-p1-membership-swept-advance-handoff.md`](docs/research/2026-06-03-p1-membership-swept-advance-handoff.md) +and the visual-gate/render-residuals note +[`docs/research/2026-06-03-p1-visual-gate-render-residuals.md`](docs/research/2026-06-03-p1-visual-gate-render-residuals.md)). + +**A6.P1 + A6.P2 + A6.P3 slice 1 SHIPPED 2026-05-21.** **A6.P3 slice 2 v2 SHIPPED 2026-05-22** (commit `f8d669b`): tried removing the L622 per-tick CP seed (`892019b` v1) but it broke BSP step_up at the last step of stairs; diff --git a/docs/research/2026-06-03-p2-door-stepup-handoff.md b/docs/research/2026-06-03-p2-door-stepup-handoff.md new file mode 100644 index 00000000..f688d2da --- /dev/null +++ b/docs/research/2026-06-03-p2-door-stepup-handoff.md @@ -0,0 +1,171 @@ +# P2 pickup handoff — door / building-shell collision = BSP Path 5 grounded step-up + +> **Canonical pickup for the next session.** Branch `claude/thirsty-goldberg-51bb9b` +> (do NOT branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on +> Windows; launch logs are UTF-16. Read this FIRST. + +## State both altitudes + +- **Milestone:** M1.5 — Indoor world feels right. +- **Effort:** the VERBATIM spatial-pipeline port (master plan: + `docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md`). +- **P1 (membership) = DONE.** Proven to already match retail; the "0/11 lag" was a cdb capture + artifact. Merged to `main` + pushed to both remotes (HEAD `f0d37d8`). See + `docs/research/2026-06-03-p1-membership-swept-advance-handoff.md` (RESOLVED banner) + + `memory/project_retail_membership_criterion.md`. +- **P2 (door / building-shell collision) = IN PROGRESS, root cause LOCALIZED.** The fix is the next step. +- **Next concrete step:** read+compare acdream `find_walkable`/`step_sphere_down` vs retail + `BSPTREE::step_sphere_down` (pc:323665) + `find_walkable`, pin the step-up CLIMB divergence, and + drive `B1_GroundedMover_LowStep_StepsUp` + the door apparatus RED→GREEN. + +## TL;DR (the P2 finding) + +All **5 failing Core tests** localize to **BSP Path 5 (the grounded `Contact + StepSphereUp` branch)**: +| Test | Symptom | +|---|---| +| `BSPStepUpTests.B1_GroundedMover_LowStep_StepsUp` | grounded mover wall-slides a **walkable 0.25 m step** instead of stepping up (`CurPos.Z` stays 0). The cleanest isolation of the bug. | +| `BSPStepUpTests.D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames` | airborne tall-wall sliding-normal count (`Expected: 2`) — Path 4/5 sliding-normal family. | +| `DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug` | synthetic door + grounded off-center: now blocks at tick 0 with an `(0,0,1)` up-normal + goes airborne (Path 5 step-up artifact, not a faithful door block). | +| `DoorBugTrajectoryReplayTests.LiveCompare_DoorOffCenterWalkthrough_Tick13558` | replay of captured live tick diverges from the engine (documents-the-bug). | +| `DoorBugTrajectoryReplayTests.LiveCompare_DoorBlocksFromOutside_Tick22760` | same, outdoor-block tick. | + +These have been the documented baseline RED set for a while (they are P2's target). They did NOT +regress this session — P1's work only touched conformance tests + docs. + +## Root cause — PRECISELY localized (the whole upstream chain is verified faithful) + +For B1 (the cleanest case), the path is reached + dispatched correctly; the divergence is deep in the +climb. Verified faithful and ruled out this session (DO NOT re-investigate these — see DO-NOT-RETRY): + +1. **Path 5 dispatch is reached.** `BSPStepUpFixtures.MakeGroundedTransition` sets `State = Contact | + OnWalkable` + `StepDown=true` — but that `StepDown` is `ObjectInfo`'s flag; the Path 3 dispatch + gates on the `SpherePath.StepDown` flag (only set during a step-down probe), so the **main sweep + correctly lands in Path 5's `Contact` branch** (`BSPQuery.cs:1814`). +2. **Recursion guard passes.** `if (engine is not null && !path.StepUp && !path.StepDown) return + StepSphereUp(...)` (`BSPQuery.cs:1848`) — on the main sweep both `SpherePath` flags are false → + `StepSphereUp` → `DoStepUp` IS called on the wall hit. +3. **`DoStepUp` (`TransitionTypes.cs:3254`) = faithful port of retail `CTransition::step_up` + (pc:273099).** Same structure: clear contact-plane, `stepDownHeight = OnWalkable ? StepUpHeight : + 0.04`, `walkableAllowance = OnWalkable ? GetWalkableZ() : LandingZ`, call `DoStepDown(...)`, return + its result. (acdream adds a restore-contact-plane-on-failure block — benign.) +4. **`DoStepDown` (`TransitionTypes.cs:3074`) = faithful port of retail `CTransition::step_down` + (pc:272946).** Skips the down-offset when `StepUp` is set, runs `TransitionalInsert(5)`, accepts + iff `OK && ContactPlaneValid && ContactPlane.N.z >= walkableZ`, then placement-validates. + +**So the divergence is INSIDE the step-up climb:** `DoStepDown` → `TransitionalInsert(5)` → Path 3 +`step_sphere_down` → **`find_walkable`'s upper-floor find + sphere-up-adjust when `sp.StepUp=true`**. +It fails to locate/lift onto the 0.25 floor within the 0.30 budget → `DoStepDown` returns false → +`StepUpSlide` → wall-slide → `Z=0`. The retail oracle for the climb is `BSPTREE::step_sphere_down` +(`@ 0x53a210` pc:323665) + `BSPNODE/BSPLEAF::find_walkable` + `adjust_sphere_to_plane`. + +acdream code map: `BSPQuery.StepSphereUp` (`:1372`), `BSPQuery.step_sphere_down` (`:1206`), +`BSPQuery.find_walkable` (`:693`), `BSPQuery.AdjustSphereToPlane` (search it), `Transition.DoStepUp` +(`:3254`), `Transition.DoStepDown` (`:3074`). + +## ⚠ TOOLING NOTE (cost me a probe cycle — don't repeat) + +**xunit swallows `Console.WriteLine`.** The built-in `ACDREAM_DUMP_STEPUP=1` trace (in `DoStepUp`) +and the `[step-walk]` probe (`PhysicsDiagnostics.ProbeStepWalkEnabled`) both print via `Console` → +they do NOT surface in `dotnet test ... -l "console;verbosity=detailed"`. The tests that DID show +output used `ITestOutputHelper` (`_out.WriteLine`). So to trace which climb condition fails +(`TransitionalInsert(5)` result / contact-plane / `N.z` / placement), **add an `ITestOutputHelper`-based +trace to B1 (or a focused harness) — don't rely on the `Console` probes inside the engine.** + +## DO-NOT-RETRY (verified faithful this session) + +1. Path 5 dispatch / the Contact-branch reachability — confirmed B1 reaches Path 5. +2. The recursion guard / `StepSphereUp` call — confirmed `DoStepUp` is called. +3. `DoStepUp` vs retail `step_up` — faithful, ruled out. +4. `DoStepDown` vs retail `step_down` — faithful, ruled out. +The bug is downstream in `find_walkable`/`step_sphere_down`'s **step-up adjust**. Start there. + +## Next steps (evidence-first — the door saga burned many SPECULATIVE fixes; do NOT repeat) + +1. **Read+compare** acdream `BSPQuery.find_walkable` (`:693`) + `step_sphere_down` (`:1206`) + + `AdjustSphereToPlane` against retail `BSPTREE::step_sphere_down` (pc:323665) + `BSPNODE/BSPLEAF:: + find_walkable` + `adjust_sphere_to_plane`. Focus on the `step_up==1` path: how retail lifts the + sphere onto a step within `step_down_amt`, and where acdream fails to. +2. **Instrument B1 with `ITestOutputHelper`** (Console is swallowed — see tooling note) to pin which + condition fails: does `TransitionalInsert(5)` return OK? is `ContactPlaneValid` set? is the landing + `N.z >= walkableZ`? does placement (`TransitionalInsert(1)`) reject? `B1` is sub-second, headless. +3. **Fix the climb verbatim** (cite the decomp anchor), drive `B1` GREEN, then `B2` (must still + block the 5 m wall), then the door apparatus (`Apparatus_Grounded_50cmOffCenter…` flips to + block-not-walkthrough → rewrite its assertion to `Assert.True(blocked) && pos.Y < 12.0`), then the + 2 `LiveCompare` ticks, then `D4`. +4. **Definitive cross-check if the decomp is ambiguous:** cdb-attach to retail at a Holtburg cottage + doorway, break on `CTransition::step_up`/`step_down`/`BSPTREE::step_sphere_down`, walk a low step + + the door, capture what retail does. PDB MATCHES; tooling in `tools/cdb/` (CLAUDE.md "Retail debugger + toolchain"). Needs the user's retail client up + walking. +5. **User visual gate:** at a doorway, walk through cleanly (foot Y stable, no oscillation), walls + block; step up a low step (cottage stair) climbs. + +## Test baseline (going into the P2 fix) + +Core **1309 pass / 5 fail / 1 skip** — the 5 are exactly this P2 target (`Apparatus_Grounded…`, +`LiveCompare_DoorOffCenterWalkthrough_Tick13558`, `LiveCompare_DoorBlocksFromOutside_Tick22760`, +`BSPStepUpTests.D4…`, `BSPStepUpTests.B1…`). Conformance 60 pass / 1 skip / 0 fail. App 177 green. + +## Parked (do NOT touch without explicit user approval) + +- **(a)–(d) membership cleanups** — approval-gated refactors of WORKING code (CLAUDE.md "don't replace + working retail-faithful logic without approval"): (a) remove redundant `ResolveCellId` (already out + of the prod per-frame path; survives only in the `DataCache==null` test fallback); (b) unify the + forked `find_env_collisions`; (c) replace the `CheckBuildingTransit` bridge with intrinsic building + stabs in `find_transit_cells`; (d) make the per-cell ObjCell graph the collision authority (collision + still uses the landblock-wide `ShadowObjectRegistry`). The one soft spot: outdoor→indoor `0031↔0170` + building-entry is live-clean but NOT conformance-locked (rides on `CheckBuildingTransit`). +- **Render residuals (P3/P4)** — the VISIBLE doorway seam is now in the render path: the flap = + camera-collision residual (chase eye drifts out of the cell → viewer-cell flips; master-plan P3, + `SmartBox::update_viewer`); the void = unported PView seal (P4). Membership (physics) is correct. + See `docs/research/2026-06-03-p1-visual-gate-render-residuals.md`. Master-plan order: P2 → P3 → P4. + +--- + +## FRESH-SESSION PROMPT (copy-paste) + +``` +Continue the VERBATIM retail spatial-pipeline port for acdream. Branch claude/thirsty-goldberg-51bb9b +(do NOT branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows; +launch logs are UTF-16. + +STATE: M1.5 (Indoor world feels right). P1 membership = DONE (proven to already match retail; the +"0/11 lag" was a cdb capture artifact; merged + pushed, HEAD f0d37d8). P2 (door/building-shell +collision) = IN PROGRESS, root cause LOCALIZED to BSP Path 5 grounded step-up. The fix is the job. + +READ FIRST (canonical, in order): +1. docs/research/2026-06-03-p2-door-stepup-handoff.md (THIS handoff — the localization, the + DO-NOT-RETRY list, the tooling note, the next steps). +2. docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md (§3 P2; §1/§2 B3/B4). +3. docs/research/2026-05-25-door-bug-partial-fix-shipped.md (the door saga state + its do-not list). + +THE FINDING: all 5 failing Core tests localize to BSP Path 5 (grounded Contact + StepSphereUp). For +B1 (cleanest: a grounded mover wall-slides a walkable 0.25 m step with a 0.30 m budget, Z stays 0), +the whole upstream chain is VERIFIED FAITHFUL + correctly reached — Path 5 dispatch, the recursion +guard, DoStepUp (= retail CTransition::step_up pc:273099), DoStepDown (= retail step_down pc:272946). +The divergence is INSIDE the step-up CLIMB: DoStepDown → TransitionalInsert(5) → Path 3 +step_sphere_down → find_walkable's upper-floor find + sphere-up-adjust when sp.StepUp=true. It fails +to lift onto the 0.25 floor → StepUpSlide → wall-slide. + +DO NOT RE-INVESTIGATE (verified faithful): Path 5 dispatch, the recursion guard, DoStepUp, DoStepDown. +DO NOT speculate on the BSP fix without apparatus (the door saga burned many speculative fixes). +TOOLING: xunit swallows Console.WriteLine — the ACDREAM_DUMP_STEPUP / [step-walk] probes don't surface +in the runner; instrument B1 with ITestOutputHelper to trace the climb conditions. + +THE JOB (P2 fix, evidence-first): +1. Read+compare acdream BSPQuery.find_walkable (:693) / step_sphere_down (:1206) / AdjustSphereToPlane + vs retail BSPTREE::step_sphere_down (pc:323665) + BSPNODE/BSPLEAF::find_walkable + adjust_sphere_to_plane, + focused on the step_up==1 climb. Pin the divergence (instrument B1 with ITestOutputHelper if needed). +2. Port the climb verbatim (cite the anchor). Drive RED→GREEN: B1 (steps up), then B2 (still blocks the + 5 m wall), then DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter (flips to block — then + rewrite its documents-the-bug assertion to Assert.True(blocked) && pos.Y < 12.0), then the 2 + LiveCompare ticks, then D4. +3. If the decomp is ambiguous: cdb-attach to retail at a cottage doorway (break on step_up/step_down/ + step_sphere_down) — needs the user's retail client. PDB matches; tools/cdb/. +4. USER VISUAL GATE: walk through a doorway cleanly (foot Y stable, walls block); step up a cottage + stair (climbs). + +TEST BASELINE: Core 1309 pass / 5 fail (the P2 target above) / 1 skip; Conformance 60 pass / 1 skip; +App 177 green. PARKED (need explicit approval): the (a)-(d) membership cleanups + the render residuals +(P3/P4 — the visible doorway flap/void). Do NOT speculate a Path-5 fix before the climb divergence is +pinned with evidence. +``` diff --git a/docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md b/docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md index 47e7406a..41c9d3cb 100644 --- a/docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md +++ b/docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md @@ -121,8 +121,32 @@ The render rides on stable membership + a stable viewer. Port bottom-up so each **P0 first** because it's the apparatus that makes "verbatim" checkable. - **P0 — Conformance apparatus (before any port).** Headless fixtures of the Holtburg cottage neighborhood (cells `0xA9B4003x` + `0xA9B4017x`, the building stab, the cellar) loaded from the real dats. Golden tests that assert *retail* outcomes: `find_cell_list` returns the same cell as a captured retail trace at the threshold; `point_in_cell` matches; the PVS visible-set for a given (cell, eye) matches. Use the existing `ACDREAM_CAPTURE_RESOLVE` + cdb retail traces. **This is how we know a port is verbatim, not vibes.** -- **P1 — Membership (A) + uniform collision (B1).** Port `find_cell_list`/`find_transit_cells`/`find_building_transit_cells`/`add_all_outside_cells` intrinsic; delete `CheckBuildingTransit`. Port uniform `find_env_collisions` (no fork). One `point_in_cell` criterion everywhere. **Gate:** stand in the cottage doorway — the cell does NOT ping-pong (`[cell-transit]` DELTA=0 standing still, no `0031↔0170↔0171`); walk in/out is a clean monotonic cell sequence. -- **P2 — Door/building-shell collision (B3/B4).** Fix the push-back bounce (the 3 failing Core door tests go green). **Gate:** stand in the doorway — no position oscillation (foot Y stable); walk through cleanly; walls block. +- **P1 — Membership (A) — ✅ DONE 2026-06-03 (premise REVERSED).** acdream's membership ALREADY + matches retail; the believed "0/11 lag" was a cdb CAPTURE ARTIFACT (`CPhysicsObj::SetPositionInternal` + calls `change_cell` at :283456 BEFORE `set_frame` writes `m_position` at :283458, so the golden + paired each frame's new cell with the previous frame's position). An aligned re-capture + (`tools/cdb/find-cell-list-capture-aligned.cdb`) makes the production gate read **9/9 with NO code + change**, and the live visual gate is clean (`[cell-transit]` monotonic, no ping-pong). Both retail + and acdream pick with center-only `point_in_cell` on `global_sphere[0]`; commit via + `validate_transition` = the `find_cell_list` pick — structurally identical. See + `docs/research/2026-06-03-p1-membership-swept-advance-handoff.md` (RESOLVED banner) + + `memory/project_retail_membership_criterion.md`. **RE-SCOPED:** the original P1 + deletes/unifications — (a) demote/remove `ResolveCellId` (already out of the prod per-frame path), + (b) unify the forked `find_env_collisions`, (c) replace `CheckBuildingTransit` with intrinsic + building stabs in `find_transit_cells`, (d) make the per-cell ObjCell graph the collision authority + — are now **approval-gated refactors of WORKING code, NOT bug fixes**; they wait for explicit user + approval (CLAUDE.md "don't replace working retail-faithful logic without approval"). One soft spot: + outdoor→indoor `0031↔0170` building-entry is live-clean but NOT conformance-locked (rides on the + un-ported `CheckBuildingTransit` bridge). +- **P2 — Door/building-shell collision (B3/B4) — IN PROGRESS, root cause LOCALIZED 2026-06-03.** The + 5 failing Core tests localize to **BSP Path 5 grounded step-up**. The wrappers — Path 5 dispatch + (Contact branch), the recursion guard, `DoStepUp` (= retail `CTransition::step_up` pc:273099), + `DoStepDown` (= retail `step_down` pc:272946) — are verified faithful + correctly reached; the + divergence is in the step-up CLIMB itself (`find_walkable`/`step_sphere_down`'s upper-floor find + + sphere-up-adjust when `sp.StepUp=true`; retail `BSPTREE::step_sphere_down` pc:323665). Cleanest + isolation: `B1_GroundedMover_LowStep_StepsUp` (wall-slides a walkable 0.25 m step). Pickup: + `docs/research/2026-06-03-p2-door-stepup-handoff.md`. **Gate:** stand in the doorway — no position + oscillation (foot Y stable); walk through cleanly; walls block; step up a low cottage stair (climbs). - **P3 — Camera viewer-cell (C1/C3).** Port `find_visible_child_cell` + the faithful `update_viewer` start-cell/fallbacks. **Gate:** `viewerCell` is stable + correct as the camera orbits across boundaries (no `[flap-cam]` thrash). - **P4 — PView render (D2–D9), the core.** Replace `PortalVisibilityBuilder`/`ProjectToNdc`/`ScreenPolygonClip` with `ConstructView`/`InitCell`/`ClipPortals`/`GetClip`/`AddViewToPortals` + `portal_view_type`/`update_count`; re-port `DrawCells`' seal verbatim. **Gate:** cottage interior sealed (opaque walls, no transparent/flap, no void), sky/terrain through the door only. - **P5 — Outside-looking-in (D8).** `DrawPortal` + `ConstructView(CBldPortal)`. **Gate:** from the street the interior renders through the door (no see-through box). From abbd7615ee335a05b80ae9e5bfd0ef633b32576c Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 09:32:55 +0200 Subject: [PATCH 002/172] fix(p2): Path 5 near-miss = retail num_sphere>1 gate (fixes B1 step-up wedge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-06-03 handoff localized the failing Core tests to the BSP Path 5 step-up CLIMB (find_walkable/step_sphere_down). An ITestOutputHelper capture of B1 disproved that: the climb code is correct (matches ACE Polygon.adjust_sphere_to_plane / BSPTree.step_sphere_down exactly). The real bug is the A6.P4 near-miss dispatch in FindCollisions' Path 5 (Contact branch), which diverged from retail three ways: 1. Recorded a near-miss NegPolyHit UNCONDITIONALLY. Retail gates both set_neg_poly_hit calls behind `if (num_sphere > 1)` (acclient_2013_pseudo_c.txt:323852). 2. Checked the foot sphere's near-miss before the head's. Retail checks the head (sphere1) first. 3. Mapped foot->neg_step_up=false / head->true. Retail maps head(index 0) ->false (slide), foot(index 1)->true (step-up), per SPHEREPATH::set_neg_poly_hit (:323279, neg_step_up = arg2). For B1's single foot sphere, the spurious near-miss -> outer loop `!NegStepUp -> SetCollisionNormal + Collided` -> revert: the grounded mover wedged at x=0.1 and never advanced to the wall to step up. With the verbatim gate, a single-sphere near-miss records nothing, the sphere advances, full-hits the wall, and step_sphere_up climbs the 0.25 m step (verified via probe capture: foot ends at (0.6, 0, 0.25)). The Holtburg cottage door still blocks faithfully (door slab (0,-1,0) normal, stops in front of the door) when the scenario has a real floor — confirmed this change does not regress the door. The two BSPQueryTests Path5 near-miss tests used a single sphere (the very non-retail assumption that caused this wedge); converted to the production 2-sphere shape where the head sphere records the near-miss, matching retail. Core 1312 pass / 4 fail (the 4 pre-existing: 3 door documents-the-bug + D4 airborne, none regressed here); App 177 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Physics/BSPQuery.cs | 73 +++++++++++-------- .../Physics/BSPQueryTests.cs | 32 ++++++-- 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 3da87204..ef047675 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1855,21 +1855,38 @@ public static class BSPQuery return TransitionState.Slid; } - // Sphere 0 didn't fully hit. Test sphere 1 (head sphere). - ResolvedPolygon? hitPoly1 = null; - bool hit1 = false; - + // Sphere 0 didn't fully hit. Per retail, the head-sphere test AND + // both near-miss dispatches are gated behind num_sphere > 1 (a head + // sphere exists). A single-sphere mover that only near-misses — e.g. + // a grounded foot sphere brushing a walkable step's top face on a + // parallel (movement ⟂ normal, front-face-culled) move — records NO + // neg-poly hit and is allowed to advance. It full-hits the + // obstacle's vertical face on a later sub-step and step_sphere_up's + // there. Recording a spurious foot near-miss here is what wedged a + // grounded mover against a walkable low step (it never advanced far + // enough to full-hit the wall and step up). See B1. + // + // Retail BSPTREE::find_collisions Contact branch + // (acclient_2013_pseudo_c.txt:323838-323881, @0x53a630-0x53a6fb): + // if (num_sphere > 1) { + // sphere1 full hit -> slide_sphere (Slid) + // else sphere1 near-miss -> set_neg_poly_hit(0, n1) (neg_step_up=0 -> outer slide) + // else sphere0 near-miss -> set_neg_poly_hit(1, n0) (neg_step_up=1 -> outer step_up) + // } + // SPHEREPATH::set_neg_poly_hit (acclient_2013_pseudo_c.txt:323279) + // assigns neg_step_up = arg2, so the HEAD near-miss (index 0) slides + // and the FOOT near-miss (index 1) steps up. The head test wins when + // both spheres near-miss (sphere1 is checked first). if (sphere1 is not null) { - Vector3 contact1 = Vector3.Zero; - hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement, - ref hitPoly1, ref contact1); + ResolvedPolygon? hitPoly1 = null; + Vector3 contact1 = Vector3.Zero; + bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement, + ref hitPoly1, ref contact1); if (hit1) { - // Sphere 1 full hit while sphere 0 had only near-miss - // (hitPoly0) — retail calls slide_sphere here. Record - // collision + slide. + // Sphere 1 (head) full hit → slide_sphere. if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) PhysicsDiagnostics.LastBspHitPoly = hitPoly1; @@ -1878,26 +1895,24 @@ public static class BSPQuery collisions.SetSlidingNormal(worldNormal); return TransitionState.Slid; } - } - // Neither sphere fully hit. Record neg-poly hit if either had a - // near-miss polygon. Retail's set_neg_poly_hit with stepUp=false - // for sphere 0's near-miss, stepUp=true for sphere 1's near-miss. - // Outer transitional_insert loop then dispatches via slide_sphere - // (stepUp=false) or step_up + step_up_slide (stepUp=true). - if (hitPoly0 is not null) - { - if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) - PhysicsDiagnostics.LastBspHitPoly = hitPoly0; - NegPolyHitDispatch(path, hitPoly0, stepUp: false, localToWorld); - return TransitionState.OK; - } - if (hitPoly1 is not null) - { - if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) - PhysicsDiagnostics.LastBspHitPoly = hitPoly1; - NegPolyHitDispatch(path, hitPoly1, stepUp: true, localToWorld); - return TransitionState.OK; + // Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide. + if (hitPoly1 is not null) + { + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = hitPoly1; + NegPolyHitDispatch(path, hitPoly1, stepUp: false, localToWorld); + return TransitionState.OK; + } + + // Sphere 0 (foot) near-miss → neg_poly_hit, neg_step_up = true → outer step_up. + if (hitPoly0 is not null) + { + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = hitPoly0; + NegPolyHitDispatch(path, hitPoly0, stepUp: true, localToWorld); + return TransitionState.OK; + } } return TransitionState.OK; diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs index 1c2ef410..c2474e4e 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs @@ -592,9 +592,15 @@ public class BSPQueryTests // returns 0 because dpMove >= 0. But the polygon pointer IS recorded // (line 00539509 `*arg5 = this` fires when static-overlap result != 0). // - // Expected: Path 5 (Contact branch) sees hitPoly0 != null but hit0 == - // false → NegPolyHitDispatch fires → path.NegPolyHit = true → state - // returns OK. + // Retail records the near-miss ONLY when num_sphere > 1 (a head sphere + // exists) — acclient_2013_pseudo_c.txt:323852-323879. So this is a + // TWO-sphere mover (foot + head). Both spheres near-miss; retail checks + // the HEAD (sphere1) first → set_neg_poly_hit(0,..) → neg_step_up=FALSE + // (slide). Expected: hit1 == false but hitPoly1 != null → + // NegPolyHitDispatch fires → path.NegPolyHit = true, NegStepUp = false, + // state returns OK. (A single-sphere mover records NOTHING here — that + // retail gate is what un-wedged the B1 grounded step-up; see + // BSPStepUpTests.B1.) var (root, resolved) = BuildSingleWallBsp(); var transition = new Transition(); @@ -607,6 +613,12 @@ public class BSPQueryTests Origin = new Vector3(0f, 0.3f, 0f), Radius = 0.48f, }; + // Head sphere — also statically overlaps the wall (Z within poly range). + var localSphere1 = new Sphere + { + Origin = new Vector3(0f, 0.3f, 1.0f), + Radius = 0.48f, + }; // localCurrCenter: previous sphere center. movement = current - curr. // Move +X by 0.05 m (small tick step parallel to the wall). @@ -617,7 +629,7 @@ public class BSPQueryTests resolved, transition, localSphere, - localSphere1: null, + localSphere1: localSphere1, localCurrCenter: localCurrCenter, localSpaceZ: Vector3.UnitZ, scale: 1.0f, @@ -639,7 +651,10 @@ public class BSPQueryTests { // Same overlap geometry, but motion is AWAY from the wall (+Y). // moveDot = dot((0,+1,0), (0,+1,0)) = +1 > 0 → cull rejects. - // Static overlap is still true, so retail records the polygon. + // Static overlap is still true, so retail records the polygon — but + // only because this is a TWO-sphere mover (num_sphere > 1 gate, + // acclient_2013_pseudo_c.txt:323852). The head sphere's near-miss + // records the NegPolyHit (neg_step_up=FALSE). var (root, resolved) = BuildSingleWallBsp(); var transition = new Transition(); @@ -651,6 +666,11 @@ public class BSPQueryTests Origin = new Vector3(0f, 0.3f, 0f), Radius = 0.48f, }; + var localSphere1 = new Sphere + { + Origin = new Vector3(0f, 0.3f, 1.0f), + Radius = 0.48f, + }; var localCurrCenter = localSphere.Origin - new Vector3(0f, 0.05f, 0f); var state = BSPQuery.FindCollisions( @@ -658,7 +678,7 @@ public class BSPQueryTests resolved, transition, localSphere, - localSphere1: null, + localSphere1: localSphere1, localCurrCenter: localCurrCenter, localSpaceZ: Vector3.UnitZ, scale: 1.0f, From f984e92e3713d4019cee3eb32d26dd9117f40ecb Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 09:35:14 +0200 Subject: [PATCH 003/172] =?UTF-8?q?docs(p2):=20correct=20the=20handoff=20?= =?UTF-8?q?=E2=80=94=20B1=20was=20the=20Path=205=20near-miss=20gate,=20not?= =?UTF-8?q?=20the=20climb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior localization (step-up CLIMB) was disproven by an ITestOutputHelper capture. Records the real root cause (A6.P4 near-miss missing retail's num_sphere>1 gate, fixed in abbd761), that the door blocks faithfully with a real floor, and that the remaining red tests are separate (apparatus synthetic-floor artifact, LiveCompare buggy-captures, D4 airborne) — not simple "flip to green" targets. Next is the user visual gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-03-p2-door-stepup-handoff.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/research/2026-06-03-p2-door-stepup-handoff.md b/docs/research/2026-06-03-p2-door-stepup-handoff.md index f688d2da..215848c5 100644 --- a/docs/research/2026-06-03-p2-door-stepup-handoff.md +++ b/docs/research/2026-06-03-p2-door-stepup-handoff.md @@ -1,5 +1,37 @@ # P2 pickup handoff — door / building-shell collision = BSP Path 5 grounded step-up +> **🔴 CORRECTED 2026-06-04 — the localization below (the step-up CLIMB) was WRONG; B1 is FIXED.** +> An `ITestOutputHelper` capture of B1 (xunit swallows `Console.WriteLine`) proved the climb code +> (`find_walkable`/`AdjustSphereToPlane`/`step_sphere_down`/`DoStepUp`/`DoStepDown`) is **correct** and +> matches ACE exactly. The real B1 bug was the **A6.P4 near-miss dispatch in `BSPQuery.FindCollisions` +> Path 5 (Contact branch)**, which diverged from retail three ways: (1) recorded a near-miss +> `NegPolyHit` **unconditionally** — retail gates both `set_neg_poly_hit` calls behind +> `if (num_sphere > 1)` (`acclient_2013_pseudo_c.txt:323852`); (2) checked the foot near-miss before +> the head's (retail checks the head/sphere1 first); (3) reversed the `neg_step_up` mapping (retail: +> head index0→FALSE/slide, foot index1→TRUE/step-up, per `SPHEREPATH::set_neg_poly_hit` :323279). +> For B1's single foot sphere the spurious near-miss → outer `!NegStepUp → Collided` → revert → the +> mover wedged at x=0.1, never reached the wall to step up. **Verbatim fix committed (`abbd761`):** +> the gate+order+mapping now match retail; B1 climbs (foot→(0.6,0,0.25)); the Holtburg door blocks +> faithfully (slab `(0,-1,0)` normal) when the scenario has a real floor. +> +> **Remaining red (NOT simple flips — all separate from B1):** +> - `Apparatus_Grounded_50cmOffCenter` — its tick-0 `(0,0,1)` "block" is a **synthetic-test artifact**: +> the apparatus sets `terrain=-1000` so the only BSP is the door slab; the contact-maintenance +> step-down finds no floor underfoot → false Collided/revert, then the mover walks through. With a +> real floor (`terrain=0`) the door blocks faithfully at Y≈11.5 with `(0,-1,0)`. Fix = give the +> grounded test a real floor + assert the block normal is the door's ±Y (NOT the tick-0 `(0,0,1)` +> contact-maintenance hiccup, which is a separate cold-seed first-frame artifact). Do **NOT** just +> flip to `Assert.True(blocked)` — that blesses the artifact. +> - `LiveCompare_DoorOffCenterWalkthrough_Tick13558` / `_DoorBlocksFromOutside_Tick22760` — compare +> against captured **buggy-live** positions; a correct fix makes the harness diverge (blocks earlier). +> Re-baseline to the corrected behavior or retire as documents-the-bug. +> - `D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames` — **airborne (Path 6)**, a separate +> sliding-normal-persistence issue, unrelated to the Path 5 grounded near-miss. Pre-existing. +> +> See `memory/project_p2_door_stepup_findings.md`. **Next: USER VISUAL GATE** (walk through a cottage +> door cleanly; step up a stair) — the authoritative P2 acceptance. The original (wrong) analysis is +> retained below for the record. + > **Canonical pickup for the next session.** Branch `claude/thirsty-goldberg-51bb9b` > (do NOT branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on > Windows; launch logs are UTF-16. Read this FIRST. From 0935a315bfa78d63ddd1ff1627e0009c340b59ab Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 10:28:59 +0200 Subject: [PATCH 004/172] fix(p2): port retail slide_sphere for head near-miss (cellar wall-slide) Replace the A6.P4 Collided shortcut in transitional_insert's neg_step_up==0 branch with the faithful CSphere::slide_sphere. Retail (acclient_2013_pseudo_c.txt:273350-273351, CTransition::transitional_insert): neg_step_up==0 (HEAD-sphere near-miss) -> slide_sphere -> continue the insert loop; neg_step_up==1 (foot) -> step_up_slide. The acdream foot branch already did that; only the head branch took the shortcut (SetCollisionNormal + return Collided = dead hard-stop). The slide itself is the existing SlideSphereInternal (Sphere.SlideSphere port): it strips the into-wall component and keeps the tangential crease (collisionNormal x contactPlane.N). Surfaced by the B1 near-miss-gate fix (abbd761): once the grounded mover climbs onto the cottage floor, its head sphere brushes the cellar stairwell walls and the old hard-stop wedged it (2026-06-04 live capture: 274 (0,-1,0) + 78 (1,0,0) hits, out==current, dead oscillation). Post-fix capture shows 96 hit-and-advanced frames (the body slides along the walls). Visual-verified 2026-06-04: closed cottage door still BLOCKS (no walkthrough -- drifts sideways along it, retail-faithful); cellar ascent now works (was always stuck). An intermittent corner-wedge (slide into the -Y/+X wall corner) remains -- separate finer issue, under investigation. Core 1310 pass / 4 fail (pre-existing: 3 door documents-the-bug + D4 airborne). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 37 +++++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index a2f5a5ce..9879e624 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1070,18 +1070,33 @@ public sealed class Transition } else { - // Retail CSphere::slide_sphere — the full retail version - // adjusts sphere position via add_offset_to_check_pos + - // returns Adjusted (on success) or Collided (degenerate). - // Our simpler response: record the collision normal + - // return Collided. The outer engine sees Collided and - // does NOT advance the sphere position — block achieved. + // Retail neg_step_up==0 (HEAD-sphere near-miss) → CSphere::slide_sphere, + // then the insert loop continues (re-tests at the slid position): + // CTransition::transitional_insert, acclient_2013_pseudo_c.txt:273350-273351 + // if (sphere_path.neg_step_up == 0) + // edi = CSphere::slide_sphere(global_sphere, sphere_path, + // collision_info, neg_collision_normal, + // global_curr_center); // - // A6.P4 door inside-out fix (2026-05-25): user-visible - // blocking is the goal (retail behavior); the full - // slide-position adjustment can be a later iteration. - ci.SetCollisionNormal(sp.NegCollisionNormal); - return TransitionState.Collided; + // Earlier (A6.P4, 2026-05-25) this branch returned Collided as a + // simplification so closed doors would block. But that hard stop + // ALSO wedged a grounded mover whose HEAD sphere brushed a wall + // while moving along it — e.g. exiting the Holtburg cottage cellar: + // the body reached the cottage floor (Z=94) but oscillated against + // the stairwell walls with no slide (2026-06-04 live capture, 16k + // frames: 274 (0,-1,0) + 78 (1,0,0) hits, out==current). The + // faithful slide_sphere slides tangentially along the wall (crease = + // collisionNormal × contactPlane.Normal), which un-wedges the cellar + // AND still blocks a closed door — the into-door (+Y) component is + // removed and only the tangential X slide survives, so there is no + // walkthrough. + var slideRes = SlideSphereInternal( + sp.NegCollisionNormal, sp.GlobalCurrCenter[0].Origin); + if (slideRes == TransitionState.Collided) + return TransitionState.Collided; // degenerate slide → hard stop + // Slid / Adjusted / OK → re-test at the (slid) CheckPos, mirroring + // retail's insert-loop continuation after slide_sphere. + continue; } } From 5ad897b0a5bb53579f489ecd5b865b0753e6ea0d Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 11:27:44 +0200 Subject: [PATCH 005/172] docs(p2): cellar corner-wedge pinned to step-up-onto-floor (retail cdb) + trace apparatus Live retail cdb trace (tools/cdb/cellar-corner-escape.cdb) of the Holtburg cottage cellar-top corner decodes the ground truth: retail escapes by step_sphere_up->step_up (196x vs 38 near-misses), transitioning the contact plane from the ramp (N.z=0.78) onto the flat cottage floor (N.z=1.0, 76 landings). acdream slides at the lip and never makes that ramp->floor transition -> the intermittent cellar wedge. So the remaining cellar bug is the #98-core step-up-onto-cottage-floor (DoStepDown / step_sphere_down / find_walkable), which the shipped B1 (abbd761) + slide_sphere (0935a31) fixes got close to but didn't finish. Door still blocks; generic step-up climbs; cellar went always-stuck -> works-mostly. Next (handoff doc): instrument acdream's OWN corner path (does step_up fire at the lip and fail to land on the cottage floor?) before porting the lip-climb -- no guessing (#98 saga rule). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-04-p2-cellar-corner-stepup-handoff.md | 91 +++++++++++++++++++ tools/cdb/cellar-corner-escape.cdb | 51 +++++++++++ 2 files changed, 142 insertions(+) create mode 100644 docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md create mode 100644 tools/cdb/cellar-corner-escape.cdb diff --git a/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md new file mode 100644 index 00000000..fbc71f17 --- /dev/null +++ b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md @@ -0,0 +1,91 @@ +# P2 pickup — cellar-top corner wedge = step-up-onto-cottage-floor (retail cdb pinned) + +> **Canonical pickup, 2026-06-04.** Branch `claude/thirsty-goldberg-51bb9b` (do NOT +> branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on +> Windows; launch logs are UTF-16. + +## State both altitudes +- **Milestone:** M1.5 — Indoor world feels right. +- **Phase:** P2 (door / building-shell collision) of the verbatim spatial-pipeline port. +- **Shipped this session (committed, branch HEAD `0935a31`):** + - `abbd761` — **B1 fix:** Path 5 (Contact) near-miss dispatch ported verbatim — gate + behind `num_sphere > 1`, head-first order, `neg_step_up` mapping (head→false/slide, + foot→true/step-up). Retail `transitional_insert`/`find_collisions` Contact branch + (`acclient_2013_pseudo_c.txt:323838-323881`, `set_neg_poly_hit` :323279). Fixed the + B1 grounded-step-up wedge (the handoff's "climb" localization was WRONG — proved via + `ITestOutputHelper` capture). + - `0935a31` — **slide_sphere fix:** head near-miss (`neg_step_up==0`) now calls the + faithful `CSphere::slide_sphere` (existing `SlideSphereInternal`) + continues the + insert loop, replacing the A6.P4 `Collided` shortcut (`transitional_insert` + pc:273350-273351). + - `f984e92` — docs (corrected the prior P2 handoff). +- **Visual-verified 2026-06-04:** generic step-up climbs; **closed cottage door still + BLOCKS** (slides tangentially, no walkthrough — regression check passed); **cellar + ascent went from ALWAYS-stuck → WORKS-MOSTLY.** +- **Remaining:** an **intermittent corner-wedge** at the cellar-top lip. Retail is + always smooth there (user-confirmed). So it's a real bug. + +## The cdb-pinned finding (retail ground truth) +`tools/cdb/cellar-corner-escape.cdb` traced live retail at the cellar-top corner +(decode: `parse_corner_log.py`; raw: `cellar-corner-retail.log`). Retail escapes the +corner by **STEP-UP, not slide:** +- `step_sphere_up`→`step_up` fired **196×** vs only **38 near-misses**. `step_up` + normals: +X wall ×78, **ceiling `(0,0,-1)` ×36**, +Y wall ×32, −X wall ×18, ramp + slope `(0,−0.62,0.78)` ×11, −Y wall ×10, floor `(0,0,1)` ×10. So retail step-ups + against EVERY grounded full-hit at the corner. +- **Contact plane transitions ramp `N.z=0.78` (×63) → flat cottage floor `N.z=1.0` + (×76).** That's the escape: retail **climbs the lip off the ramp ONTO the cottage + floor.** +- The user's "run in place against the ceiling (not stuck)" = `step_up` failing on the + ceiling normal `(0,0,-1)` → `step_up_slide` (transient; steer out). + +**Divergence pinned:** retail escapes by **stepping up onto the cottage floor**; +acdream **slides at the lip and never makes the ramp→floor transition**. The slide +itself (the `0935a31` fix) is correct + working; the gap is the **final lip-climb**. +This is the **original #98 core** — `DoStepDown`/`step_sphere_down` finding + landing +on the cottage floor — which B1+slide got close to but didn't finish. + +## Next step (evidence-first — #98 saga rule: do NOT guess) +1. **Instrument acdream's OWN corner path.** The captures so far + (`cellar-up-capture*.jsonl`, `door-recheck-capture.jsonl`) have positions/normals but + NOT the path. Need to answer: at the cellar-top lip, does acdream's `step_sphere_up`→ + `DoStepUp` FIRE and FAIL to land on the cottage floor (DoStepDown can't find + `N.z=1.0` within `StepUpHeight=0.6`), or does it not fire (the hit goes to the slide + path instead)? Relaunch acdream with `ProbeBuildingEnabled` (→ `[neg-poly-dispatch]`/ + `[bsp-test]`) + `ACDREAM_DUMP_STEPUP=1` + `ProbeStepWalkEnabled` (→ `[step-walk]`), + reproduce the wedge, read the path. (xunit-swallow doesn't apply to the live app — + Console probes DO surface in the launch log.) +2. **Compare to retail's 196 step_up / ramp→floor transition** and port the missing + lip-climb verbatim. Likely in `DoStepDown` (`TransitionTypes.cs:3074`) / + `BSPQuery.step_sphere_down` (:1206) / `find_walkable` (:693) — the cottage-floor + find+land. Retail anchors: `CTransition::step_up` pc:273099, `step_down` pc:272946, + `BSPTREE::step_sphere_down` pc:323665, `CObjCell::find_env_collisions` (the + walkable-refresh that overwrites the contact plane ramp→floor). +3. **USER VISUAL GATE:** cellar ascent clean (no intermittent wedge); door still blocks; + generic step-up still climbs. + +## Apparatus (committed / available) +- `tools/cdb/cellar-corner-escape.cdb` — retail corner trace (step_up/step_sphere_up/ + neg_poly_hit/contact_plane counts + args; 30K threshold — TOO HIGH for these + lower-frequency BPs, lower to ~3000 next time so it auto-detaches in one wedge). +- `parse_corner_log.py` — decodes the cdb log (hex→float, histograms). +- Captures (UNCOMMITTED, in worktree root, ~32 MB each — do NOT commit): + `cellar-up-capture.jsonl` (v1, pre-slide-fix wedge), `cellar-up-capture-v2.jsonl` + (post-slide-fix: 96 hit-and-advanced slide frames), `door-recheck-capture.jsonl`, + `cellar-corner-retail.log` (the retail cdb trace). +- `analyze_cellar.py` / `analyze_v2.py` — ad-hoc capture analyzers (capture-specific). + +## Test baseline +Core 1310 pass / 4 fail / 1 skip. The 4 fails are pre-existing documents-the-bug / +separate-issue: `DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter` +(synthetic-test artifact — terrain=-1000, no queryable floor; NOT a real door-block +failure — see `memory/project_p2_door_stepup_findings.md`), 2× `DoorBugTrajectoryReplay +LiveCompare_*` (compare against captured-BUGGY-live positions; need re-baseline), and +`BSPStepUpTests.D4` (airborne Path 6 sliding-normal persistence — separate). App 177 green. + +## Do NOT +- Guess a step-up fix without acdream's corner path trace (the #98 saga burned 10+ guesses). +- Flip `Apparatus_Grounded_50cmOffCenter` to `Assert.True(blocked)` — it blocks via a + synthetic-floor artifact, not a faithful door block. +- Re-investigate B1 (the near-miss gate) or the slide_sphere head-near-miss path — both + shipped + verified. diff --git a/tools/cdb/cellar-corner-escape.cdb b/tools/cdb/cellar-corner-escape.cdb new file mode 100644 index 00000000..5ed430ea --- /dev/null +++ b/tools/cdb/cellar-corner-escape.cdb @@ -0,0 +1,51 @@ +$$ +$$ Cellar-top corner-escape capture — 2026-06-04 (P2 cellar wedge) +$$ +$$ Q: at the cellar-top corner, the acdream player (on the ramp, pushing +Y) +$$ wedges against a -Y wall (head-sphere near-miss -> slide, oscillates, no +$$ net +Y). Does RETAIL escape by STEPPING UP over the lip, or by SLIDING, +$$ and is the near-miss a HEAD (neg_step_up=0) or FOOT (neg_step_up=1) hit? +$$ That decides whether the fix is slide-convergence vs step-up-at-the-lip. +$$ +$$ Unique (non-overloaded) symbols only -> robust counts + key args: +$$ BPA CTransition::step_up(this, Vector3* normal) step-up ATTEMPT +$$ BPB BSPTREE::step_sphere_up(this@ecx, CTransition*, V3*) step-up DISPATCH +$$ BPC SPHEREPATH::set_neg_poly_hit(this, int negStepUp, V3* n) +$$ negStepUp 0=head(slide) 1=foot(step-up); also logs n +$$ BPD COLLISIONINFO::set_contact_plane(this, Plane*, isWater) +$$ landing signal: N.z ~1 = cottage floor (escaped), ~0.7 = ramp +$$ +$$ thiscall: ecx=this; args [esp+4],[esp+8],... CSphere center: x+0 y+4 z+8 r+0xc +$$ Plane (set_contact_plane arg): N.x+0 N.y+4 N.z+8 d+0xc +$$ Floats logged as 32-bit hex (dwo + %08X); decode with tools/cdb/decode_retail_hex.py +$$ +$$ Threshold 30000 hits across A+B+C (auto-detach via qd). BPD unbounded. +$$ Walk up the cellar stairs into the corner and let it wedge once or twice; +$$ the wedge generates the hits, so 30K auto-detaches within an attempt or two. + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\cellar-corner-retail.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +r $t0 = 0 +r $t1 = 0 +r $t2 = 0 +r $t3 = 0 +r $t4 = 0 + +$$ BPA: CTransition::step_up(this, Vector3* normal) — did retail try to step up the lip? +bp acclient!CTransition::step_up "r $t1=@$t1+1; r $t0=@$t0+1; .printf /D \"[BPA] step_up #%d nx_h=0x%08X ny_h=0x%08X nz_h=0x%08X\\n\", @$t1, dwo(poi(@esp+4)+0), dwo(poi(@esp+4)+4), dwo(poi(@esp+4)+8); .if (@$t0 >= 30000) { qd } .else { gc }" + +$$ BPB: BSPTREE::step_sphere_up — step-up dispatch count. +bp acclient!BSPTREE::step_sphere_up "r $t2=@$t2+1; r $t0=@$t0+1; .printf /D \"[BPB] step_sphere_up #%d\\n\", @$t2; .if (@$t0 >= 30000) { qd } .else { gc }" + +$$ BPC: SPHEREPATH::set_neg_poly_hit(this, int negStepUp, Vector3* n) +bp acclient!SPHEREPATH::set_neg_poly_hit "r $t3=@$t3+1; r $t0=@$t0+1; .printf /D \"[BPC] neg_poly_hit #%d negStepUp=%d nx_h=0x%08X ny_h=0x%08X nz_h=0x%08X\\n\", @$t3, dwo(@esp+4), dwo(poi(@esp+8)+0), dwo(poi(@esp+8)+4), dwo(poi(@esp+8)+8); .if (@$t0 >= 30000) { qd } .else { gc }" + +$$ BPD: COLLISIONINFO::set_contact_plane(this, Plane*, isWater) — landing signal (unbounded). +bp acclient!COLLISIONINFO::set_contact_plane "r $t4=@$t4+1; .printf /D \"[BPD] contact_plane #%d Nz_h=0x%08X d_h=0x%08X\\n\", @$t4, dwo(poi(@esp+4)+8), dwo(poi(@esp+4)+0xc); gc" + +.printf "cellar-corner-escape armed: A=step_up B=step_sphere_up C=neg_poly_hit D=contact_plane; 30K threshold\\n" + +g From 664101f08fcceb328a90dceca69269d74f40144e Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 11:39:21 +0200 Subject: [PATCH 006/172] =?UTF-8?q?docs(p2):=20re-diagnose=20cellar=20wedg?= =?UTF-8?q?e=20=E2=80=94=20cell-resolver=20ping-pong,=20not=20step-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instrumented acdream at the cellar lip (ACDREAM_DUMP_STEPUP=1): step-up WORKS (518 attempts, 220 SUCCESS landing the candidate on the cottage floor Z=94.0, matching retail's landings), but the committed CurPos never advances -- every success is reverted, and [cell-transit] shows ResolveCellId ping-ponging every tick at the 3-cell junction (0xA9B40175<->0174<->0171, reason=resolver). So the wedge is a MEMBERSHIP cell-resolution instability reverting a working step-up -- NOT a collision/step-up bug, NOT edge-slide. Notably this contradicts the master-plan P1 claim that ResolveCellId was demoted out of the per-frame path: it is STILL driving per-frame cell changes here and is unstable. Fix direction = the parked, approval-gated (a) ResolveCellId demotion/stickiness (membership), now justified as a real bug by live evidence. Collision-side fixes (abbd761 B1, 0935a31 slide_sphere) are correct + kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-04-p2-cellar-corner-stepup-handoff.md | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md index fbc71f17..62570079 100644 --- a/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md +++ b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md @@ -1,9 +1,29 @@ -# P2 pickup — cellar-top corner wedge = step-up-onto-cottage-floor (retail cdb pinned) +# P2 pickup — cellar-top corner wedge = cell-resolver ping-pong (re-diagnosed) reverting a WORKING step-up > **Canonical pickup, 2026-06-04.** Branch `claude/thirsty-goldberg-51bb9b` (do NOT > branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on > Windows; launch logs are UTF-16. +> **🔴 RE-DIAGNOSED 2026-06-04 (acdream corner trace) — the cellar wedge is a MEMBERSHIP +> bug, NOT collision.** The "## The cdb-pinned finding" below (retail steps up onto the +> floor) is correct for RETAIL, but instrumenting acdream (`ACDREAM_DUMP_STEPUP=1`) at the +> lip showed acdream's **step-up WORKS**: 518 attempts, **220 SUCCESS** landing the +> candidate on the cottage floor (`CheckPos Z=94.0`, normal `(0,0,1)`), 298 FAILED, +> alternating. But the **committed `CurPos` never advances** — it stays on the ramp at +> `(…,9.70,93.41)`; every success is REVERTED. `[cell-transit]` shows a **cell-resolver +> ping-pong every tick at the 3-cell junction: `0xA9B40175↔0174↔0171`, `reason=resolver`**. +> So `ResolveCellId` flips the cell each frame → the floor-landing is validated against the +> wrong cell + rejected → revert → oscillation → wedge. **NOT step-up (works), NOT +> edge-slide.** It's the #98/"Finding-3" cell-ping-pong family. **The fix is membership/ +> cell-resolution stability at the junction — the PARKED, approval-gated (a) `ResolveCellId` +> demotion/stickiness from the master plan** (P1 claimed it was demoted out of the per-frame +> path, but this trace shows it's STILL driving per-frame cell changes here + unstable). The +> collision-side fixes (B1 `abbd761`, slide_sphere `0935a31`) are correct + KEEP. Apparatus: +> `acdream-corner-capture.jsonl` + the `stepup:`/`[cell-transit]` lines in +> `launch-acdream-corner.log`. **Next:** pin whether the commit-rejection is caused by the +> resolver flip (trace `ResolveWithTransition` validate/commit vs the cell change at the +> lip), then stabilize membership there (do NOT touch step-up/slide — they work). + ## State both altitudes - **Milestone:** M1.5 — Indoor world feels right. - **Phase:** P2 (door / building-shell collision) of the verbatim spatial-pipeline port. From 57435e912b3d8ac5a06cafedd7b01ac743aee7d2 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 4 Jun 2026 11:43:11 +0200 Subject: [PATCH 007/172] =?UTF-8?q?docs(p2):=20fresh-session=20kickoff=20p?= =?UTF-8?q?rompt=20=E2=80=94=20principled=20P1=20membership=20fix=20(user-?= =?UTF-8?q?approved)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends the copy-paste kickoff prompt for the next session: pursue the principled P1 fix for the cellar-lip cell-resolver ping-pong (demote ResolveCellId / make the swept curr_cell the per-frame membership authority), NOT a stickiness band-aid. Captures the evidence, apparatus, retail anchors, do-not list, and test baseline. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-04-p2-cellar-corner-stepup-handoff.md | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md index 62570079..feacd0d4 100644 --- a/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md +++ b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md @@ -104,8 +104,72 @@ LiveCompare_*` (compare against captured-BUGGY-live positions; need re-baseline) `BSPStepUpTests.D4` (airborne Path 6 sliding-normal persistence — separate). App 177 green. ## Do NOT -- Guess a step-up fix without acdream's corner path trace (the #98 saga burned 10+ guesses). +- Guess (the #98 saga burned 10+ speculative fixes) — pin the mechanism with the apparatus first. +- Add a `ResolveCellId` stickiness clamp / suppression flag — the user chose the **principled** + P1 demotion, not a band-aid (no-workarounds rule). - Flip `Apparatus_Grounded_50cmOffCenter` to `Assert.True(blocked)` — it blocks via a synthetic-floor artifact, not a faithful door block. -- Re-investigate B1 (the near-miss gate) or the slide_sphere head-near-miss path — both - shipped + verified. +- Re-investigate B1 (`abbd761`) or slide_sphere (`0935a31`) — both shipped + verified + correct. + +## FRESH-SESSION KICKOFF PROMPT (copy-paste) — user-approved 2026-06-04: principled P1 membership fix + +``` +Continue the VERBATIM retail spatial-pipeline port for acdream. Branch claude/thirsty-goldberg-51bb9b +(do NOT branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows; +launch logs are UTF-16. + +STATE: M1.5 (Indoor world feels right). P2 COLLISION = DONE + shipped: B1 near-miss gate (abbd761) + +slide_sphere head-near-miss (0935a31). Generic step-up climbs; the closed cottage door BLOCKS (no +walkthrough); step-up AT THE CELLAR LIP works (220 successful candidate-landings on the cottage floor). +The remaining intermittent CELLAR-ASCENT WEDGE is RE-DIAGNOSED (live acdream + retail cdb traces) to a +MEMBERSHIP cell-resolver ping-pong — NOT collision. The user APPROVED the PRINCIPLED P1 fix (demote +ResolveCellId / swept curr_cell as per-frame authority), NOT a stickiness band-aid. + +READ FIRST (in order): +1. docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md — RE-DIAGNOSIS banner + full evidence. +2. memory/project_p2_door_stepup_findings.md — RE-DIAGNOSIS 2026-06-04 entry + shipped fixes + do-not. +3. memory/project_retail_membership_criterion.md — P1 membership context (swept curr_cell pick). +4. docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md — §A membership + A1–A9, §1 KEEP/REPLACE/DELETE (ResolveCellId -> spawn/teleport seed; per-frame from swept curr_cell), + parked (a)–(d). + +THE FINDING (evidence): at the Holtburg cottage cellar-top lip (3-cell junction), acdream step-up +SUCCEEDS — lands CheckPos on the cottage floor (Z=94.0, normal (0,0,1)) 220/518 times, matching retail. +But committed CurPos never advances (stays on the ramp ~(…,9.70,93.41)); every success is REVERTED +because the cell PING-PONGS every tick (0xA9B40175<->0174<->0171, [cell-transit] reason=resolver) -> the +floor-landing is validated against the wrong cell + rejected. Retail (cdb) is smooth: step_up + contact +plane transitions ramp N.z=0.78 -> flat floor N.z=1.0 (76 landings), no cell ping-pong. This CONTRADICTS +P1's claim that ResolveCellId was demoted out of the per-frame path. + +THE JOB (evidence-first; do NOT guess): +1. PIN the exact code path producing the per-frame [cell-transit] reason=resolver ping-pong at the lip + (is it PhysicsEngine.ResolveCellId despite P1's demotion claim, the swept advance, or + PlayerMovementController.UpdateCellId/UpdatePlayerCurrCell?), and CONFIRM the resolver flip CAUSES the + step-up commit-rejection (re-validation against the flipped cell) vs being a symptom. +2. PORT THE PRINCIPLED P1 FIX: make the swept curr_cell (find_cell_list pick over the uniform candidate + set) the per-frame membership authority at this junction; demote ResolveCellId to spawn/teleport seed. + Retail anchors: A1 CObjCell::find_cell_list 0x52b4e0 pc:308742; A8 change_cell/SetPositionInternal + 0x513390/0x515330; A7 transitional_insert/validate_transition/check_other_cells. The cell must NOT + flip out from under a committed step-up. NO stickiness band-aid. +3. RED->GREEN: deterministic test for the lip junction (cell stable after step-up) + keep B1/B2/B3/door + tests green. USER VISUAL GATE: cellar ascent clean (no wedge); door still blocks; generic step-up climbs. + +APPARATUS (in the worktree): +- acdream captures: acdream-corner-capture.jsonl (lip wedge: step-up-works + cell ping-pong), + cellar-up-capture-v2.jsonl, cellar-up-capture.jsonl (JSON Lines, ACDREAM_CAPTURE_RESOLVE, IsPlayer). +- Retail cdb: cellar-corner-retail.log + tools/cdb/cellar-corner-escape.cdb. Decode: parse_corner_log.py + / tools/cdb/decode_retail_hex.py. +- Probes: ACDREAM_PROBE_CELL=1 ([cell-transit]), ACDREAM_DUMP_STEPUP=1 (stepup:), ACDREAM_PROBE_RESOLVE=1 + ([resolve]), ACDREAM_CAPTURE_RESOLVE=. Live launch per CLAUDE.md "Running the client". +- cdb on retail at the lip (break CObjCell::find_cell_list / change_cell / SetPositionInternal) if the + decomp is ambiguous. PDB matches; tools/cdb/. Lower the trace threshold (~3000) so it auto-detaches in + one wedge. + +DO NOT: re-investigate B1/slide_sphere (shipped, correct); add a ResolveCellId stickiness/suppression +band-aid (user chose principled); flip Apparatus_Grounded_50cmOffCenter to Assert.True(blocked) +(synthetic-floor artifact); guess. + +TEST BASELINE: Core 1310 pass / 4 fail / 1 skip (the 4: Apparatus_Grounded_50cmOffCenter [synthetic-floor +artifact], 2x DoorBugTrajectoryReplay LiveCompare_* [captured-buggy-live, re-baseline], BSPStepUpTests.D4 +[airborne Path 6, separate]); App 177 green. Branch HEAD: 664101f (+ this commit). +``` From bc1be26907cce0711f757055184503113110c0ec Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 08:30:36 +0200 Subject: [PATCH 008/172] test(p2): faithful cellar-lip wedge reproduction + investigation apparatus (no fix yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 / M1.5 "blocked at the last step" cellar-lip wedge. This session built a faithful deterministic reproduction and peeled the cause through six evidence-disproven framings to one bounded question. NO fix landed — the last layers were each disproven by evidence, and guessing at the load-bearing collision code is the saga's failure mode. Apparatus: - CellarLipWedgeTests.cs + Fixtures/cellar-lip/ (3 real cell dumps + wedge-records.jsonl = 29 captured ACDREAM_CAPTURE_RESOLVE wedge calls). Replays the exact calls + body-before through the lip-cell engine: all 29 reproduce at 0% advance in <200 ms. Tests are documents-the-bug / diagnostics (GREEN while the wedge exists). - TEMP probes ([path5-wall]/[fw-enter]/[find-walkable] in BSPQuery; [neg-poly]/[stepsphereup]/ [stepdown-decide]/CheckOtherCells cn/sn/negHit in TransitionTypes), gated on ACDREAM_PROBE_INDOOR_BSP, marked STRIP. TransitionTypes neg-poly shortcut has a reverted-fix comment (slide attempt didn't clear the wedge). - tools/cdb/retail-*-trace.cdb (retail cdb traces). Findings (handoff: docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md, see the "NEXT-SESSION KICKOFF" at top): - Flat-floor contact plane is retail-faithful (v1 trace, full-file correlation). NOT the bug. - PosHitsSphere cull sign is retail-faithful (cdb -z verified; the Binary Ninja `test ah,N; jp` parity-jump reads inverted — caught + reverted a wrong fix from that mis-read). - Sphere radius correct (0.48 player / 0.30 camera probe). - Retail connector cell 0xA9B40175 never blocks (CEnvCell::find_collisions trace: 0 Collided/Slid). - PINNED: during the step-up's step-down, BSPQuery.FindWalkableInternal is never called for cell 0171, so the cottage floor (poly 0x0023, Z=94) is never tested as walkable -> no contact plane -> step-up fails -> StepUpSlide=Collided -> wedge. Next: trace FindEnvCollisions -> FindCollisions path dispatch for 0171 during StepDown=true (why StepSphereDown/find_walkable is skipped), port retail, validate via CellarLipWedgeTests, regress DoorBugTrajectoryReplayTests + visual gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-04-p2-cellar-corner-stepup-handoff.md | 15 + ...6-04-p2-cellar-lip-flatfloor-cp-handoff.md | 445 ++ src/AcDream.Core/Physics/BSPQuery.cs | 32 +- src/AcDream.Core/Physics/TransitionTypes.cs | 18 +- .../Fixtures/cellar-lip/0xA9B40171.json | 3773 +++++++++++++++++ .../Fixtures/cellar-lip/0xA9B40174.json | 583 +++ .../Fixtures/cellar-lip/0xA9B40175.json | 413 ++ .../Fixtures/cellar-lip/wedge-records.jsonl | 29 + .../Physics/CellarLipWedgeTests.cs | 446 ++ tools/cdb/retail-connector-collide-trace.cdb | 34 + tools/cdb/retail-flatfloor-trace.cdb | 26 + tools/cdb/retail-lip-trace.cdb | 13 + 12 files changed, 5824 insertions(+), 3 deletions(-) create mode 100644 docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md create mode 100644 tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40171.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40174.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40175.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/cellar-lip/wedge-records.jsonl create mode 100644 tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs create mode 100644 tools/cdb/retail-connector-collide-trace.cdb create mode 100644 tools/cdb/retail-flatfloor-trace.cdb create mode 100644 tools/cdb/retail-lip-trace.cdb diff --git a/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md index feacd0d4..bf6195e8 100644 --- a/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md +++ b/docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md @@ -1,5 +1,20 @@ # P2 pickup — cellar-top corner wedge = cell-resolver ping-pong (re-diagnosed) reverting a WORKING step-up +> **🟢 SUPERSEDED 2026-06-04 PM — the wedge is NOT membership and NOT a reverted landing.** +> Canonical findings + full evidence chain are now in `memory/project_p2_door_stepup_findings.md` +> (the "RE-DIAGNOSIS 2" + "SLIDE LOCALIZED" + "FAILING CONDITION PINNED" entries). One-line summary: +> a live **retail cdb trace** proved retail's carried cell ALSO flips 0174/0175/0171 at the lip yet +> retail is smooth → membership ruled out. The wedge is a **step-up coin-flip**: the step-up's +> internal step-down FAILS to set a contact plane on the FLAT cottage floor (`cpValid=False`, +> `walkInterp=1.0`) while it works on the ramp slope. acdream's `StepSphereDown`/`AdjustSphereToPlane` +> are FAITHFUL to retail (verified vs `find_walkable` pc:326793 + `adjust_sphere_to_plane` pc:322032), +> so the obvious "set the CP anyway" fix DIVERGES from retail — do NOT ship it. **NEXT STEP (ready):** +> run `tools/cdb/retail-flatfloor-trace.cdb` on the live retail client at the cellar lip to see whether +> retail's `step_sphere_down` returns 3 (sets CP) or 1 (no CP) on the flat floor — that decides where +> retail establishes the flat-floor contact plane, then port it. 4 TEMP probes (gated on +> ACDREAM_PROBE_INDOOR_BSP, marked STRIP) are uncommitted in the worktree. The text below is HISTORY. + + > **Canonical pickup, 2026-06-04.** Branch `claude/thirsty-goldberg-51bb9b` (do NOT > branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on > Windows; launch logs are UTF-16. diff --git a/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md b/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md new file mode 100644 index 00000000..1d430a25 --- /dev/null +++ b/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md @@ -0,0 +1,445 @@ +# P2 cellar-lip wedge — CANONICAL handoff (flat-floor contact-plane coin-flip) + +## ▶ NEXT-SESSION KICKOFF (START HERE — supersedes everything below) + +**State:** M1.5 / P2 cellar-lip "blocked at the last step" wedge. A FAITHFUL deterministic reproduction now +exists. The cause has been peeled through SIX evidence-disproven framings to one bounded question. No fix +landed (intentionally — the last layers were each disproven; do NOT guess, the collision code is load- +bearing). Branch `claude/thirsty-goldberg-51bb9b` (do NOT branch/worktree; do NOT push w/o asking; NEVER +`git stash`/`gc`). PowerShell on Windows; launch logs UTF-16. Use `superpowers:systematic-debugging`. + +**READ (in order):** (1) THIS file's `## SESSION-END` + `UPDATE 1` + `UPDATE 2` (the live captures + the +pinned root cause) and `CORRECTION 1`/`CORRECTION 2` (CP + cull are RETAIL-FAITHFUL, proven). (2) +`memory/project_p2_door_stepup_findings.md`. + +**DONE — faithful apparatus (uncommitted in worktree; RECOMMEND committing first):** +`tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs` + `Fixtures/cellar-lip/0xA9B4017{1,4,5}.json` + +`Fixtures/cellar-lip/wedge-records.jsonl` (29 real `ACDREAM_CAPTURE_RESOLVE` wedge calls). Replays the EXACT +captured calls (seed body-before, real climb dir −X,+Y) through the lip-cell engine — **all 29 reproduce +the wedge at 0% advance in <200 ms.** Tests (all GREEN as documents-the-bug/diagnostics): +`DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge`, `Diagnostic_ReplayLiveWedgeRecords_Advance`, +`Diagnostic_ReplayFloorCpRecord_StepUpProbes` (→ `%TEMP%/lip-wedge-stepup.log`), + 2 synthetic. + +**DISPROVEN — do NOT re-investigate:** flat-floor CP (retail also no-CP, smooth — Correction 1); the +`PosHitsSphere` cull sign (retail-faithful, `cdb -z`-verified — Correction 2); sphere radius (0.48=player +correct, 0.30=camera probe); the A6.P4 neg-poly `Collided`→`slide_sphere` shortcut (fix attempted + reverted, +didn't clear it — the slide returns offset=0 then degenerates to Collided on re-check). + +**PINNED ROOT-CAUSE LAYER (UPDATE 2):** during the step-up's step-down (`DoStepUp`→`DoStepDown`→ +`TransitionalInsert(5)`), **`BSPQuery.FindWalkableInternal` is NEVER called for cell 0171** (confirmed after +a CLEAN rebuild — `[fw-enter]` TEMP probe fires 0×). So the cottage floor (0171 poly 0x0023, n=(0,0,1), +Z=94) is **never tested as walkable** → no contact plane → step-down rejects (`cpValid=False`) → step-up +fails → `StepUpSlide=Collided` → wedge. `[other-cells] iter=0171 result=OK` is returned WITHOUT reaching +`StepSphereDown`→`find_walkable`. + +**THE JOB (bounded, evidence-first — NO speculative edits):** +1. Trace `Transition.FindEnvCollisions` (TransitionTypes.cs) → `BSPQuery.FindCollisions` PATH DISPATCH for + cell 0171 when `StepDown=true`. Find WHY `StepSphereDown`/`FindWalkableInternal` is skipped — candidates: + entry `NodeIntersects` early-OK; Path 1 (Placement) taken (DoStepDown's placement insert); the primary + 0175 collision returning Collided (the −X-wall Path-5 `StepSphereUp`, `stepUp=stepDown=False` = the OUTER + non-step pass) short-circuiting before `CheckOtherCells(0171)`; or StepDown not actually set on that call. + Use the `[fw-enter]`/`[find-walkable]` TEMP probes — **FORCE A CLEAN REBUILD (`Remove-Item obj,bin`) for + any Core probe edit; `dotnet test`/`dotnet build` incremental did NOT pick up new BSPQuery.cs probes + (cost two probe rounds).** +2. Port retail's behavior (oracle: `CEnvCell::find_collisions` pc:309560 → `BSPTREE::find_collisions` + pc:323725 Path-3 → `BSPTREE::step_sphere_down` pc:323665 → `BSPLEAF::find_walkable` pc:326793). Verify + how retail's step-down reaches `find_walkable` on the cottage floor where acdream's does not. +3. Fix → VALIDATE: flip `CellarLipWedgeTests.DocumentsWedge_LiveFloorCp_*` to `advance>0.25·requested` → + GREEN; `Diagnostic_ReplayLiveWedgeRecords` advance% jumps off 0%. 4. REGRESSION: `DoorBugTrajectoryReplayTests` + + full Core suite. **VISUAL GATE: cellar ascent clean (no last-step wedge) + inn door BLOCKS + generic + step-up climbs.** + +**ENABLER:** `cdb -z "C:\Turbine\Asheron's Call\acclient.exe"` = offline static disasm + `uf` w/ PDB symbols +(no live attach); use it to verify any retail branch/offset (the cull-sign error was a BN parity-jump +mis-read — never trust BN `if(p)` for `test ah,N; jp`). + +**Apparatus (uncommitted, worktree):** the test + fixtures above; TEMP probes in `BSPQuery.cs` +(`[path5-wall]`,`[fw-enter]`,`[find-walkable]`, STRIP) + `TransitionTypes.cs` (`[neg-poly]`,`[stepsphereup]`, +`[stepdown-decide]`, CheckOtherCells cn/sn/negHit, STRIP) all gated on `ACDREAM_PROBE_INDOOR_BSP`; captures +`lip-wedge-resolve.jsonl`/`lip-cells/`/`launch-*.log`; cdb scripts `tools/cdb/retail-connector-collide-trace.cdb` +(+ flatfloor/lip); analyzers `analyze_wedge_jsonl.py`/`extract_wedge_records.py`/`analyze_v1_corr.py`; +`cdbz-disasm.txt`/`cdbz-poshits.txt`. **Test baseline:** Core prior 1310p/4f/1s + 5 GREEN lip tests; App 177. + +--- + +> **Canonical pickup, 2026-06-04 PM.** Branch `claude/thirsty-goldberg-51bb9b` (do NOT +> branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on +> Windows; launch logs are UTF-16. This SUPERSEDES the membership re-diagnosis in +> `docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md` (now history) and folds in +> the full chain from `memory/project_p2_door_stepup_findings.md`. + +--- + +## 🧪 SESSION-END 2026-06-04 PM — deterministic repro BUILT + first fix attempt REVERTED + +**Apparatus shipped (uncommitted in worktree):** `tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs` ++ fixtures `tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B4017{1,4,5}.json` (copied from `lip-cells/`). +Loads the 3 lip cells (synthetic single-leaf BSP, same as `CellarUpTrajectoryReplayTests`), seeds the +player (r=0.48, foot bottom Z=93.456 → foot-sphere center Z=93.936 = the live wedge) carried in slab +0175, drives forward. **Reproduces the wedge deterministically in <90 ms:** the player FREEZES, blocked +by the threshold slab's −X side wall (poly normal world (1,0,0)). Two tests, both GREEN as documents-the- +bug: `Diagnostic_DriveOffThreshold_DumpTrajectory` (dumps trajectory+probes to `%TEMP%/lip-wedge-diag.log` +via Console redirect) + `DocumentsWedge_PlayerFrozenAtThreshold_BlockedByMinusXWall`. + +**FIX ATTEMPT #1 — REVERTED.** Hypothesis: the A6.P4 neg-poly `NegStepUp==false` branch +(`TransitionTypes.cs` ~line 1083) returns `Collided` (a deliberate "simpler response" shortcut; the +comment says the slide was deferred), where retail dispatches `neg_step_up==0 → slide_sphere`. Replaced +it with `SlideSphereInternal(NegCollisionNormal, GlobalCurrCenter[0].Origin)` (mirroring the NegStepUp= +true branch). **Did NOT fix the wedge** → reverted. WHY: the slide returns `Slid` with **offset=0** (the +−Y displacement is already along the crease `dir=cross((1,0,0),(0,0,1))=(0,−1,0)`), so the sphere +doesn't move; the loop re-checks with `gDelta≈0` → `SlideSphere`'s `offset.LengthSquared<ε → Collided` +branch (`TransitionTypes.cs:2877`) → revert. So the bug is NOT the shortcut alone — it's the +**slide/loop-commit**: the parallel-graze slide produces no advance, and the re-check degenerates to +Collided. + +**TWO GAPS for the next pass:** +1. **Faithful repro:** the synthetic drive direction (world −Y) is a GUESS and is PARALLEL to the −X + wall (keeps grazing). The real climb direction is unknown without the exact `targetPos`. **Get a + short `ACDREAM_CAPTURE_RESOLVE=` JSONL of the live wedge** (one acdream run, wedge ~5 s) → wire + a `LiveCompare`-style test (the proven `CellarUpTrajectoryReplayTests` pattern) with the exact + currentPos/targetPos/body-before. That makes the RED test faithful + the fix validatable. +2. **The real fix is in the slide/loop:** why does retail's `slide_sphere` advance the sphere PAST the + parallel graze where acdream's returns offset=0 then degenerates to Collided on re-check? Trace + retail `CSphere::slide_sphere` (pc:321660) vs acdream `SlideSphere` (`TransitionTypes.cs:2826`) for + the parallel-wall + grounded case, AND why the loop re-check sees `gDelta≈0`. NOTE the live wedge + used the **NegStepUp=TRUE** path (StepSphereUp→StepUpSlide=Collided) while the synthetic repro used + **NegStepUp=FALSE** (neg-poly-dispatch→Collided) — BOTH end in `SlideSphere`/`SlideSphereInternal` + returning Collided, so the common fix point is `SlideSphere`'s degenerate-offset handling, not the + dispatch branch. **DOOR REGRESSION RISK:** any `SlideSphere`/neg-poly change touches the A6.P4 door + block — regression-test `DoorBugTrajectoryReplayTests` + visual-gate the inn door BLOCKS. + +**Test baseline unchanged:** the 2 new lip tests are GREEN (documents-the-bug). Build green. The reverted +fix leaves `TransitionTypes.cs` functionally identical (only an explanatory comment added at the shortcut). + +### UPDATE (same session, later) — FAITHFUL repro BUILT + ROOT CAUSE PINNED + +Got the JSONL (`ACDREAM_CAPTURE_RESOLVE` → `lip-wedge-resolve.jsonl`, 17K player records). The real climb +direction is **−X,+Y** (my synthetic −Y guess was backwards). Extracted 29 representative wedge records to +`tests/AcDream.Core.Tests/Fixtures/cellar-lip/wedge-records.jsonl` (`extract_wedge_records.py`). +`CellarLipWedgeTests` now replays the EXACT captured calls (seed body-before, replay `ResolveWithTransition` +through the lip-cell engine): **all 29 reproduce the wedge bit-faithfully (0% advance).** New tests (all +GREEN as documents-the-bug / diagnostics): `Diagnostic_ReplayLiveWedgeRecords_Advance`, +`Diagnostic_ReplayFloorCpRecord_StepUpProbes`, `DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge`. + +**ROOT CAUSE PINNED** (via `Diagnostic_ReplayFloorCpRecord_StepUpProbes` → `%TEMP%/lip-wedge-stepup.log`): +the player is at the doorway EDGE of the cottage floor. The step-up (triggered by the −X wall, normal +(1,0,0), STEEP) → step-down → multi-cell check reaches **0171 poly 0x0023 = the cottage floor** (n=(0,0,1), +world Z=94). The 0.48 sphere **OVERLAPS** it (`overlapsSphere=True`, `dist=−0.085`) — BUT it's **REJECTED +because the sphere center projects outside the floor poly's edge** (`insideEdges=False`, `gap=−0.395`). So +`[other-cells] iter=0171 result=OK` (NOT Adjusted), no contact plane is set → `[stepdown-decide] cpValid= +False accept=False` → step-up FAILS → `StepUpSlide=Collided` → wedge. Retail accepts the floor at its edge +and crosses (0175 never blocks). **This is a WALKABLE-EDGE acceptance divergence**, not a CP/cull/slide bug. + +**THE FIX (next, narrow):** compare acdream's walkable-edge math vs retail for the sphere-overlaps-floor- +but-center-outside-edge case. Actual walkable test = `BSPQuery.WalkableHitsSphere` (254) → +`PolygonHitsSpherePrecise` (overlap) + `AdjustSphereToPlane` (351); `[other-cells] result=OK` means one of +them returned false for poly 0x0023. The `[walkable-nearest]` diagnostic uses `CheckWalkable` (287, the +edge/`insideEdges` test). Retail oracle: `CPolygon::walkable_hits_sphere` (pc:323006) + +`CPolygon::check_walkable` (pc:322811) + `CPolygon::adjust_sphere_to_plane` (pc:322032). Read which one +rejects the edge-overlap and why retail accepts it. **Validate with `CellarLipWedgeTests` (flip +`DocumentsWedge_LiveFloorCp_*` to assert advance>0.25·requested). DOOR REGRESSION RISK: walkable changes +are global — run `DoorBugTrajectoryReplayTests` + visual-gate the inn door BLOCKS + generic step-up climbs.** +Apparatus: `lip-wedge-resolve.jsonl`, `Fixtures/cellar-lip/*`, `analyze_wedge_jsonl.py`, +`extract_wedge_records.py`, `%TEMP%/lip-wedge-stepup.log`. + +### UPDATE 2 — deeper: `find_walkable` is NEVER called during the step-down (cottage floor never tested) + +Drilled one layer further with TEMP probes `[fw-enter]`/`[find-walkable]` in `BSPQuery.FindWalkableInternal` +(gated on `ACDREAM_PROBE_INDOOR_BSP`, marked STRIP, uncommitted). **Confirmed after a CLEAN rebuild (deleted +obj/bin) — `[fw-enter]` fires ZERO times** while the prior probes fire. So during the step-up's step-down +(`DoStepUp`→`DoStepDown`→`TransitionalInsert(5)`), `FindWalkableInternal` is **never reached** for 0171 (nor +0175): the cottage floor poly 0x0023 is never tested by the walkable finder. `[other-cells] iter=0171 +result=OK` is returned WITHOUT `StepSphereDown`→`FindWalkableInternal`. So the "walkable-edge acceptance" +framing in UPDATE 1 is one level too shallow — the floor isn't *rejected* by the edge test, it's never +*tested* at all. Root: `FindEnvCollisions`/`BSPQuery.FindCollisions` for 0171 during the step-down returns OK +on a path BEFORE Path 3 (StepDown→StepSphereDown). **NEXT: trace `FindEnvCollisions` (TransitionTypes) → which +`FindCollisions` path 0171 takes during `StepDown=true` (entry NodeIntersects early-out? Path 1 Placement? +the primary-0175 result short-circuiting CheckOtherCells?) and why StepSphereDown/find_walkable is skipped.** +The `[stepsphereup] stepUpFlag=False stepDownFlag=False` means the −X-wall StepSphereUp is the OUTER +(non-step) collision; the step-DOWN that should find the floor is a separate inner insert that never runs +find_walkable. NOTE: a clean `dotnet build`/`dotnet test` did NOT pick up new `BSPQuery.cs` probes until +`Remove-Item obj,bin` — **force a clean rebuild when adding Core probes** (cost two probe rounds this session). + +**HONEST STATUS: NO FIX. The collision/step path is deeper than a single-line fix** — 6+ framings this +session (CP→cull→slide→neg-poly→walkable-edge→find_walkable-not-called), each disproven by evidence and the +next layer exposed. This is the systematic-debugging "question the architecture" signal. The FAITHFUL repro +(`CellarLipWedgeTests`, 29 records @0% advance) makes the next attempt iterable; the next move is the +`FindEnvCollisions`/`FindCollisions`-path trace above, NOT another speculative edit. The collision code is +load-bearing (every floor/wall/step) — do not guess. + +--- + +## ⚠️ CORRECTION 2026-06-04 (next session) — THE CP IS RETAIL-FAITHFUL; v2 IS MOOT + +**The "decisive question" below is ANSWERED from the EXISTING v1 log — no new retail trace +needed.** The v1 `retail-flatfloor-trace.log` was wrongly dismissed as a `gu` artifact. It is +**real data.** Proof (full-file correlation over all 5,349 records, `analyze_v1_corr.py`): +- sphere **z ≤ 90.0 → pure ret=3** (CP set); **z = 94.01 (the flat cottage floor) → pure ret=1 + (NO-CP), 877 records, zero ret=3**; ret mixes only in the **ramp transition zone (90–93.7)**, + which is physical. A corrupted-`eax` artifact CANNOT produce two large pure populations at + opposite Z extremes with a physical transition between — the ret tracks the input Z exactly. +- **`walk_interp = 1.0 → ret=1 (no-CP) 770×`** — i.e. retail, with `walk_interp=1.0` on the flat + floor, gets NO contact plane and is **smooth**. That is the *exact* acdream condition + (`walkInterp=1.000 cpValid=False`). +- `cdb -z acclient.exe` (offline static disasm, symbols) confirms `BSPTREE::step_sphere_down` + `+0x218`=`mov eax,3;ret` (reached only after `[eax+18h]=1` contact_plane_valid + `set_walkable`) + and `+0x227`=`mov eax,1;ret` (early `je` when `find_walkable` found nothing). So ret3↔CP-set, + ret1↔no-CP is certain. + +**ANSWER:** retail's `step_sphere_down` returns **NO-CP (ret 1)** on the flat cottage floor — +exactly like acdream — and retail crosses smoothly. **The contact plane is NOT the divergence.** +Both "if NO-CP → trace set_contact_plane callers" and "if SET-CP → divergence in StepSphereDown" +branches below rest on a FALSE premise (that retail establishes a flat-floor CP somewhere). It +does not. **Do NOT run the v2 trace; do NOT hunt a retail flat-floor CP path.** + +**REDIRECTED diagnosis (back to the connector recovery — the `RE-DIAGNOSIS 2` / `SLIDE LOCALIZED` +line in memory, which the flat-floor-CP finding had wrongly sidelined):** the wedge is the +per-cell collide on **connector 0175** returning **Slid** during the recovery, which reverts the +good floor landing. Static comparison this session confirms the recovery structures ALL MATCH +retail: `find_collisions` Contact full-hit → `step_sphere_up`; `step_up` fail → `step_up_slide` +→ `slide_sphere` (retail `CSphere::step_sphere_up` pc:321611–321638, `step_up_slide` pc:273930); +`check_other_cells` halts on Slid (4) clearing CP (pc:272717, `cdb -z` jump-table on `(result-1)`); +acdream `TransitionalInsert` *continues* (no revert) on Slid (TransitionTypes.cs:881). The SOLE +open question: **does retail's per-cell `CEnvCell::find_collisions` return Slid (recover & slide) +or OK (never hits) for connector 0175 at the lip?** +- OK in retail → acdream's connector Slid is SPURIOUS (over-detect / over-step-up 0175) → fix there. +- Slid in retail → retail slides+continues; acdream's wedge is the substep **REVERT** upstream + (`FindTransitionalPosition` / `ResolveWithTransition`), not the collide. + +**NEW decisive trace (READY, robust, no `gu`):** `tools/cdb/retail-connector-collide-trace.cdb` +breaks `CEnvCell::find_collisions+0x1e` (the SINGLE exit; `esi`=`this`, `eax`=result; cell id +`poi(esi+0x28)`), logs ret per lip cell 0xA9B4017X. Built + offset-verified entirely offline via +`cdb -z` (no live attach). **ENABLER:** `cdb -z "C:\Turbine\Asheron's Call\acclient.exe"` does +offline static disassembly with full PDB symbols — verify any trace offset without a running client. + +Everything below this banner is RETAINED FOR HISTORY (the flat-floor-CP hypothesis, now disproven). + +--- + +## ⚠️ CORRECTION 2 — 2026-06-04 PM (live retail + acdream captures; the REAL mechanism) + +Two live captures this session settled it. **Retail-connector trace** (`tools/cdb/retail-connector-collide-trace.cdb` +→ `retail-connector-collide-trace.log`, breaking `CEnvCell::find_collisions+0x1e`, single nesting-safe exit): +over ~85K samples the **connector cell 0175 returns 2692 OK + 94 Adjusted + 0 Collided + 0 Slid** — it +**never blocks**. (Floor 0171 and 0174 DO block — real cottage-room walls — so the trace is working.) So +the connector is a pure pass-through / successful-step-up in retail; acdream spuriously blocks it. + +**acdream live capture at the wedge** (`launch-lip-capture.log`, ACDREAM_PROBE_INDOOR_BSP=1 + the 4 TEMP +probes + cell dumps `lip-cells/0xA9B4017{1,4,5}.json`) — the stuck state is: +``` +[indoor-bsp] cell=0xA9B40175 lpos=(8.523,-2.251,-0.064) lprev=(8.520,-2.251,-0.064) r=0.480 result=OK +[stepdown-decide] cell=0xA9B40175 insert=OK cpValid=False cpNz=1.000 walkableZ=0.664 accept=False pos=(...,93.456) +[stepsphereup] cell=0xA9B40175 stepUpFlag=False stepDownFlag=False n=(1.00,0,0) stepped=False +[stepsphereup] cell=0xA9B40175 StepUpSlide=Collided +[indoor-bsp] cell=0xA9B40175 r=0.480 result=Collided +``` +**Decoded mechanism (this is NOT the memory's "connector Slid" — that was a pre-B1-fix state):** +- **0175 is a 0.364 m-tall threshold SLAB** (dump: 4 solid side walls at local X=7/9, Y=−2.85/1.15; + open floor/ceiling portals poly4→0171, poly5→0174; WorldTransform 180°-rot at (161.929,7.503,94)). +- The wedge uses the **r=0.48 body sphere** (Ø0.96 — *bigger than the slab is tall*), centered at world + Z=93.936 (local Z=−0.064, i.e. SUNK into the threshold, ~0.5 m below resting-on-floor Z≈94.48). +- That oversized sphere genuinely **full-hits** the −X wall (poly 3, X=9; sphere at X=8.523 reaches + X=9.003 — a **3 mm graze**; `moveDot<0` so retail would keep it too) → `BSPQuery.StepSphereUp` + (Path 5, BSPQuery.cs:1849/1380) → **`DoStepUp` fails** (its internal step-down finds no CP on the flat + floor — `[stepdown-decide] cpValid=False`, retail-faithful per Correction 1) → **`StepUpSlide` → + `SlideSphereInternal` returns `Collided`** → `FindEnvCollisions` returns Collided → wedge. The 3 mm + graze is hair-trigger → explains the intermittency. + +**CULL SIGN = RED HERRING (verified faithful).** Mid-session I believed acdream's `PosHitsSphere` cull +(`if moveDot>=0 return false`) was OPPOSITE retail. **WRONG** — `cdb -z uf acclient!CPolygon::pos_hits_sphere` +shows `test ah,5; jp +0x46`. `jp` is a **parity** jump; the cull branch is taken on EVEN parity = `{dot>=0}`. +So retail **keeps the hit when `dot<0`, culls when `dot>=0`** — IDENTICAL to acdream + ACE (`if dist>=0 +return false`). Movement convention also matches (both `check−curr`: acdream BSPQuery.cs:1663, retail +find_collisions). **Do NOT touch the cull.** The Binary Ninja pseudo-C renders `test ah,5; jp` as +`if (p) return 0` which READS like "cull when dot<0" — it is not; the parity decode is inverted. LESSON: +verify any cull/branch sign against `cdb -z`, never the BN `if(p)` rendering of a parity jump. + +**ENABLER:** `cdb -z "C:\Turbine\Asheron's Call\acclient.exe"` does offline static disasm + `uf` with full +PDB symbols — used to build/verify the connector trace AND to catch the cull-sign error. Ghidra `patchmem` +addresses do NOT match the PDB/BN addresses (0x005394f0 → `CPolygon::UnPack` in Ghidra); use `cdb -z`. + +**THE SHARP REMAINING QUESTION:** at the thin slab, **why does retail's step-up SUCCEED (climb onto the +cottage floor, find_collisions returns OK) where acdream's `DoStepUp` FAILS (no CP → StepUpSlide=Collided)?** +Sub-leads: (a) **RULED OUT — r=0.48 vs r=0.30 is two DIFFERENT movers, not a bug.** r=0.48 = the PLAYER +(`PlayerMovementController.cs:1116`, "human player radius from Setup"); r=0.30 = the CAMERA collision +probe (`PhysicsCameraCollisionProbe.cs:18` `ViewerSphereRadius=0.3`, single sphere). The smooth r=0.30 +crossings are the camera spring-arm; the wedge is the player. Player radius 0.48 is correct. So the +question is purely the player step-up, NOT the sphere. Open: why is the player SUNK to Z=93.936 (0.5 m +below resting-on-floor Z≈94.48) at the threshold — is that retail-faithful (it's mid-climb from the +cellar) or a position error? (b) does retail even full-hit 0175's wall, or does its sphere clear it +(position)? (c) the +flat-floor step-up success path (Correction 1's open question — retail's step_up establishes the floor +CP via some path acdream lacks). NEXT: either build a deterministic harness test from `lip-cells/*.json` +(place the r=0.48 sphere at the captured wedge pos, assert FindCollisions returns OK not Collided — RED→ +GREEN), or one targeted retail trace of `CSphere::step_sphere_up`/`CTransition::step_up` at 0175 (does it +return 1/OK or fall to step_up_slide?). Apparatus committed-in-worktree: `tools/cdb/retail-connector-collide-trace.cdb`, +`lip-cells/0xA9B4017{1,4,5}.json`, `launch-lip-capture.log`, `cdbz-disasm.txt`, `cdbz-poshits.txt`, +`analyze_v1_corr.py`. TEMP `[path5-wall]` probe added to BSPQuery.cs Path 5 (STRIP; was NOT in the stale +--no-build binary, so it didn't fire — rebuild to use it). + +--- + +## State both altitudes +- **Milestone:** M1.5 — Indoor world feels right. +- **Effort:** P2 of the verbatim spatial-pipeline port + (`docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md`). +- **Symptom (user words):** "run up the cellar stairs, get blocked at the last step; + sometimes through, sometimes not." Retail is always smooth there. +- **This session's outcome:** NO fix landed. The diagnosis was corrected three times with + evidence; the wedge is now precisely localized; the FINAL decisive question is still open + because the retail trace tooling (`gu` in a bp action) produced an artifact. One clean v2 + retail trace pins it. Everything is saved; resume cold. + +## The corrected diagnosis (evidence chain — all three prior theories DISPROVEN) +1. **NOT membership / cell-resolver ping-pong** (the prior handoff's claim). A live **retail + cdb trace** (`tools/cdb/retail-lip-trace.cdb` → `retail-lip-trace.log`, breaking + `CTransition::step_up`, logging `sphere_path.check_pos.objcell_id`) proved retail's carried + cell ALSO alternates `0xA9B40174/0175/0171` at the lip (181/40/17 over 238 step_ups), yet + retail crosses smoothly. So the carried-cell flip is retail-faithful — a "keep the cell + stable" / `ResolveCellId`-stickiness fix would DIVERGE from retail. Also: the production + `[cell-transit] reason=resolver` is the SWEPT `find_cell_list` pick (`RunCheckOtherCellsAndAdvance` + → `CellTransit.FindCellSet`), NOT `PhysicsEngine.ResolveCellId` (which is only the cache-null + test fallback). `result.cellId` is STABLE in runs of 100s of ticks in + `acdream-corner-capture.jsonl` (154K recs). +2. **NOT a reverted landing (the M1 guess).** In `acdream-corner-capture.jsonl` the player + reaches the floor (Z=94.0, cpz 0.78→1.00) and WALKS ON into the cottage (idx 7215→7244); + the landing commits fine. +3. **IT IS: a step-up coin-flip on the FLAT cottage floor.** The `[stepdown-decide]` probe + (in `Transition.DoStepDown`) shows the trigger unambiguously: + - `accept=True (1845×): insert=OK cpValid=True` on the RAMP (cpNz=0.781, Z≈93.3, walkInterp≈0) + - `accept=False ( 849×): insert=OK cpValid=False cpNz=1.000 walkInterp=1.000` on the FLAT floor (Z=94.0) + + The step-up's acceptance check (`insert==OK && ContactPlaneValid && cpNz>=walkableZ`, + `TransitionTypes.cs:3147`) rejects ONLY when `cpValid=False`, which happens ONLY on the FLAT + floor: the sphere is already settled → `adjust_sphere_to_plane` is a no-op → `FindWalkableInternal` + records no poly (its gate is `walkable && adjusted`, `BSPQuery.cs:736`) → `StepSphereDown` + (`BSPQuery.cs:1216`) sets no contact plane → reject. On the RAMP the sphere is always sliding + (adjusted=true) so the CP is set → accept. **Ramp = adjusting = works; flat floor = settled = + no CP = fails.** The `[stepsphereup]` probe corroborates: lip-riser step-up (cell 0171, + n=(0,-1,0)) = 443 success / 445 fail; connector +X corner wall (cell 0175, n=(1,0,0)) = 74 fail + → recursive `StepSphereUp` → `StepUpSlide` = 401 Slid / 203 Collided overall. + +## Why the OBVIOUS fix is WRONG (do not ship it) +"Just set the contact plane whenever a walkable poly is found, even without adjustment" — this +DIVERGES from retail. Verified against the decomp: +- `BSPLEAF::find_walkable` (`acclient_2013_pseudo_c.txt:326793`) gates BOTH the poly AND the + changed-flag on `walkable_hits_sphere && adjust_sphere_to_plane` — IDENTICAL to acdream's + `FindWalkableInternal`. +- `CPolygon::adjust_sphere_to_plane` (`:322032`) updates `walk_interp` and returns 1 only when + `new_interp = (1-t)*walk_interp < walk_interp`, i.e. `t>0` (the sphere must move toward the + plane). For a SETTLED sphere (`t≈0`) retail ALSO returns 0 → records no poly → sets no + step-down CP. +So acdream's `StepSphereDown` + `AdjustSphereToPlane` are FAITHFUL. Retail must establish the +flat-floor contact plane through a DIFFERENT path during the climb — that path is what's still +unknown. + +## THE DECISIVE OPEN QUESTION + the v2 trace protocol +**Does retail's `BSPTREE::step_sphere_down` SET the contact plane (ret 3) or NOT (ret 1) on the +flat floor cell (0xA9B40171)?** +- v1 trace FAILED: `gu` inside a cdb bp action ("commands skipped … target execution inside an + event handler") corrupted eax → perfect `1,3,1,3` alternation artifact (run-length=1; the + 4216/1133 histogram is meaningless). **NEVER use `gu` in a cdb bp action.** +- v2 trace READY: `tools/cdb/retail-flatfloor-trace.cdb` — stashes the cell in `$t3` at entry + via `@@c++`, counts at the two RETURN addresses (no `gu`). **STEP 0: verify the +0x218 (ret 3) + / +0x227 (ret 1) offsets against the `u acclient!BSPTREE::step_sphere_down` disassembly the + script logs, fix if needed, re-attach, THEN have the user wedge ~10s.** +- Interpretation: if floor cell 0171 is mostly **NO-CP** → retail establishes the floor CP via a + DIFFERENT path → next trace breaks `COLLISIONINFO::set_contact_plane` and logs the CALLER + (`poi(@esp)`) for normal≈(0,0,1) to find that path, then port it. If mostly **SET-CP** → the + divergence is inside acdream's `StepSphereDown`/`AdjustSphereToPlane` after all (re-read the + `walk_interp`/`t` math vs ACE). + +## Leading hypothesis (UNCONFIRMED, pending v2) +Retail's step-up ALSO "fails" on the settled flat floor (step_sphere_down no-CP) but **recovers +via `step_up_slide` smoothly**, where acdream wedges — so the divergence may be in the SLIDE +RECOVERY (`SpherePath.StepUpSlide` → `Transition.SlideSphereInternal`, the B1/`slide_sphere` +area, commits `abbd761`/`0935a31`) and/or the connector-cell-0175 `StepSphereUp` interference, +NOT the contact plane itself. The B1/slide fixes are correct FOR THE DOOR; re-investigation is +warranted FOR THE CELLAR recovery only. + +## Retail decomp anchors (verified this session) +`CTransition::step_up` pc:273099 (clears CP @273103, calls step_down) · `CTransition::step_down` +pc:272946 (the `if (step_up==0)` lower-gate @272954; `transitional_insert(5)`; accept iff +`!cond:0 && contact_plane_valid` @272968) · `BSPTREE::step_sphere_down` pc:323665 (sets +`contact_plane_valid=1` UNCONDITIONALLY when a poly is found @323711; return 3) · +`BSPLEAF::find_walkable` pc:326793 · `CPolygon::adjust_sphere_to_plane` pc:322032 · +`CTransition::transitional_insert` pc:273137 (neg_poly_hit → slide_sphere @273350) · +`CTransition::validate_transition` pc:272547 · `CTransition::check_other_cells` pc:272717. + +## acdream code map (where the fix will likely go) +`BSPQuery.StepSphereDown` (:1216) · `FindWalkableInternal` gate (:736) · `AdjustSphereToPlane` +(:351) · `FindCollisions` StepDown dispatch (:1753) · `StepSphereUp` (:1372) · `StepUpSlide` +(TransitionTypes.cs:472) / `SlideSphereInternal` · `DoStepUp` (:3269) / `DoStepDown` (:3089) · +step-up acceptance (:3147) · neg_poly dispatch gated `!StepDown && !StepUp` (:1040) · +`CheckOtherCells` (:1632) · `RunCheckOtherCellsAndAdvance` (:2158). + +## Apparatus inventory +**TEMP probes (UNCOMMITTED in worktree, gated on `ACDREAM_PROBE_INDOOR_BSP`, marked STRIP):** +`BSPQuery.NegPolyHitDispatch` → `[neg-poly]`; `BSPQuery.StepSphereUp` → `[stepsphereup]`; +`Transition.CheckOtherCells` → `cn`/`sn`/`negHit` added to `[other-cells]`; `Transition.DoStepDown` +→ `[stepdown-decide]`. +**Existing env probes:** `ACDREAM_PROBE_INDOOR_BSP=1` (→ `[indoor-bsp]`+`[other-cells]`+the 4 TEMP), +`ACDREAM_DUMP_STEPUP=1` (→ `stepup:`), `ACDREAM_PROBE_CELL=1` (→ `[cell-transit]`), +`ACDREAM_PROBE_STEP_WALK=1` (→ `[step-walk]`, very high volume), `ACDREAM_CAPTURE_RESOLVE=`. +**cdb scripts:** `tools/cdb/retail-lip-trace.cdb` (carried cell — DONE), `tools/cdb/retail-flatfloor-trace.cdb` +(v2, READY). Binary `C:\Turbine\Asheron's Call\acclient.exe` MATCHES `refs/acclient.pdb`. +**Logs (worktree root, UTF-16/big — do NOT commit):** `acdream-corner-capture.jsonl` (321MB), +`launch-corner-{innerflow,slidepoly,negpoly,ssu,decide}.log`, `retail-lip-trace.log`, +`retail-flatfloor-trace.log` (artifact), `corner-cells-audit.txt`. **Analyzers:** `analyze_corner.py`. + +## DO NOT +- Re-diagnose as membership / add `ResolveCellId` stickiness (RULED OUT by retail cdb). +- Ship "set the step-down CP without adjustment" (DIVERGES from retail — verified vs decomp). +- Use `gu` inside a cdb bp action (corrupts eax — v1 trace artifact). +- Re-investigate B1/`slide_sphere` AS THE DOOR FIX (correct); but the cellar SLIDE RECOVERY is a + legitimate new suspect. +- Flip `Apparatus_Grounded_50cmOffCenter` to `Assert.True` (synthetic-floor artifact). +- Guess the fix — the divergence is genuinely subtle (`walk_interp`/slide-recovery), pin it first. + +## Test baseline +Core 1310 pass / 4 fail / 1 skip (the 4: `Apparatus_Grounded_50cmOffCenter` [synthetic-floor], +2× `DoorBugTrajectoryReplay LiveCompare_*` [captured-buggy-live], `BSPStepUpTests.D4` [airborne +Path 6, separate]); App 177 green. Branch HEAD `664101f` + this session's UNCOMMITTED probes/docs. + +## FRESH-SESSION KICKOFF PROMPT (copy-paste) +``` +Continue the P2 cellar-lip wedge fix for acdream. Branch claude/thirsty-goldberg-51bb9b (do NOT +branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows; launch +logs are UTF-16. Use superpowers:systematic-debugging. + +READ FIRST (in order): +1. docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md (THIS handoff — canonical). +2. memory/project_p2_door_stepup_findings.md (full chain: RE-DIAGNOSIS 2 + SLIDE LOCALIZED + + FAILING CONDITION PINNED + RETAIL trace ATTEMPT #1 entries). + +STATE: M1.5. The cellar "blocked at the last step, sometimes through" wedge is RE-DIAGNOSED with +live retail cdb evidence: NOT membership (retail's carried cell flips the same way + is smooth), +NOT a reverted landing. It IS a step-up coin-flip on the FLAT cottage floor — the step-up's +internal step-down sets NO contact plane on the settled flat floor (cpValid=False, walkInterp=1.0) +so the acceptance check rejects, while it works on the ramp slope. acdream's StepSphereDown + +AdjustSphereToPlane are FAITHFUL to retail (verified vs find_walkable pc:326793 + adjust_sphere_to_plane +pc:322032), so the obvious "set the CP anyway" fix is WRONG — retail establishes the flat-floor CP +via a DIFFERENT path that is still unknown. + +THE JOB (evidence-first; do NOT guess): +1. Run the READY v2 retail trace tools/cdb/retail-flatfloor-trace.cdb (user relaunches the retail + client + walks to the cellar lip; STEP 0 = verify the +0x218/+0x227 return offsets against the + `u` disassembly the script logs BEFORE driving; NO `gu` in bp actions). Answer: does retail's + step_sphere_down set the CP (ret 3) or not (ret 1) at floor cell 0xA9B40171? +2. If mostly NO-CP → trace COLLISIONINFO::set_contact_plane callers (poi(@esp)) for normal≈(0,0,1) + to find retail's flat-floor CP path; port it. If mostly SET-CP → the divergence is in acdream's + StepSphereDown/AdjustSphereToPlane walk_interp/t math vs ACE. Leading hypothesis: retail's + step-up also "fails" on the flat floor but RECOVERS via step_up_slide smoothly where acdream + wedges → the divergence may be the SLIDE RECOVERY (StepUpSlide/SlideSphereInternal) + + connector-0175 StepSphereUp interference, NOT the CP. +3. RED→GREEN deterministic test + STRIP the 4 TEMP probes once the fix lands. USER VISUAL GATE: + cellar ascent clean (no last-step wedge); inn door still BLOCKS; generic step-up climbs. + +DO NOT: re-diagnose as membership / add ResolveCellId stickiness; ship "set the step-down CP +without adjust" (diverges from retail); use `gu` in a cdb bp action; guess. + +TEST BASELINE: Core 1310 pass / 4 fail / 1 skip (documented); App 177 green. Branch HEAD 664101f + +UNCOMMITTED TEMP probes (BSPQuery.NegPolyHitDispatch [neg-poly], BSPQuery.StepSphereUp +[stepsphereup], CheckOtherCells cn/sn/negHit, DoStepDown [stepdown-decide]) gated on +ACDREAM_PROBE_INDOOR_BSP. +``` diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index ef047675..54007754 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1374,10 +1374,27 @@ public static class BSPQuery Vector3 collisionNormal, PhysicsEngine engine) { - if (transition.DoStepUp(collisionNormal, engine!)) + bool stepped = transition.DoStepUp(collisionNormal, engine!); + + // TEMP diagnostic (cellar-lip wedge, 2026-06-04): this is the suspected + // recursive step-up onto the CONNECTOR cell during the outer check_other_cells + // (StepUp/StepDown both false). Log which cell, the input normal, whether the + // climb succeeded, and the slide fallback's result. STRIP once the wedge fix lands. + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + var p = transition.SpherePath; + Console.WriteLine(System.FormattableString.Invariant( + $"[stepsphereup] cell=0x{p.CheckCellId:X8} stepUpFlag={p.StepUp} stepDownFlag={p.StepDown} n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}) stepped={stepped} pos=({p.CheckPos.X:F3},{p.CheckPos.Y:F3},{p.CheckPos.Z:F3})")); + } + + if (stepped) return TransitionState.OK; - return transition.SpherePath.StepUpSlide(transition); + var slideRes = transition.SpherePath.StepUpSlide(transition); + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[stepsphereup] cell=0x{transition.SpherePath.CheckCellId:X8} → StepUpSlide={slideRes}")); + return slideRes; } // ------------------------------------------------------------------------- @@ -1494,6 +1511,17 @@ public static class BSPQuery path.NegStepUp = stepUp; // ACE: path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal) path.NegCollisionNormal = Vector3.Transform(hitPoly.Plane.Normal, localToWorld); + + // TEMP diagnostic (cellar-lip wedge, 2026-06-04): name the exact near-miss + // polygon (id + local normal + sides) + the foot/head mapping (stepUp) + the + // step-down flag, so we can tell which connector poly the step-up grazes. + // Gated on ACDREAM_PROBE_INDOOR_BSP. STRIP once the wedge fix lands. + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + var nl = hitPoly.Plane.Normal; + Console.WriteLine(System.FormattableString.Invariant( + $"[neg-poly] cell=0x{path.CheckCellId:X8} stepUp={stepUp} stepDownFlag={path.StepDown} poly=0x{hitPoly.Id:X4} nLocal=({nl.X:F3},{nl.Y:F3},{nl.Z:F3}) sides={hitPoly.SidesType} checkPos=({path.CheckPos.X:F3},{path.CheckPos.Y:F3},{path.CheckPos.Z:F3})")); + } return TransitionState.OK; } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 9879e624..2a6913ac 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1742,7 +1742,7 @@ public sealed class Transition string bsDesc = System.FormattableString.Invariant( $"bs=({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) br={bs.Radius:F3}"); Console.WriteLine(System.FormattableString.Invariant( - $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} {bsDesc} result={result} {polyDesc}")); + $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} {bsDesc} result={result} cn=({CollisionInfo.CollisionNormal.X:F2},{CollisionInfo.CollisionNormal.Y:F2},{CollisionInfo.CollisionNormal.Z:F2}) sn=({CollisionInfo.SlidingNormal.X:F2},{CollisionInfo.SlidingNormal.Y:F2},{CollisionInfo.SlidingNormal.Z:F2}) negHit={sp.NegPolyHit} negN=({sp.NegCollisionNormal.X:F2},{sp.NegCollisionNormal.Y:F2},{sp.NegCollisionNormal.Z:F2}) {polyDesc}")); } if (PhysicsDiagnostics.ProbeStepWalkEnabled @@ -3134,6 +3134,22 @@ public sealed class Transition sp.StepDown = false; + // TEMP diagnostic (cellar-lip wedge, 2026-06-04): why does the lip-riser + // step-up flip ~50/50? Log the step-down DECISION inputs — the + // TransitionalInsert(5) result, contact-plane validity + Z-normal, the + // walkable threshold, and WalkInterp. On the FAILING half exactly one of + // these explains the rejection. Gated on ACDREAM_PROBE_INDOOR_BSP. STRIP + // once the wedge fix lands. + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + var cpn = CollisionInfo.ContactPlane.Normal; + bool accept = transitState == TransitionState.OK + && CollisionInfo.ContactPlaneValid + && cpn.Z >= walkableZ; + Console.WriteLine(System.FormattableString.Invariant( + $"[stepdown-decide] cell=0x{sp.CheckCellId:X8} insert={transitState} cpValid={CollisionInfo.ContactPlaneValid} cpNz={cpn.Z:F3} walkableZ={walkableZ:F3} walkInterp={sp.WalkInterp:F3} accept={accept} pos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})")); + } + // Accept step-down if: // 1. Collision detection returned OK // 2. A valid contact plane was found diff --git a/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40171.json b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40171.json new file mode 100644 index 00000000..48ea0445 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40171.json @@ -0,0 +1,3773 @@ +{ + "CellId": 2847146353, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 3, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 6, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 7, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 9, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 10, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 11, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + } + ] + }, + { + "Id": 12, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 13, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 14, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 15, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 16, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + } + ] + }, + { + "Id": 17, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 18, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 19, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 20, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 22, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 23, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 9.2 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 24, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 25, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 26, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 27, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 28, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + } + ] + }, + { + "Id": 29, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 30, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + } + ] + }, + { + "Id": 31, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 32, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 33, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 34, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 35, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 36, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 37, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 38, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 39, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 40, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 41, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 42, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 43, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 44, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 45, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 46, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 47, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 48, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -5.96045E-09, + "Y": -2.4835207E-09, + "Z": 1 + }, + "D": 4.6566015E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 49, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 50, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 51, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + } + ] + }, + { + "Id": 52, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 53, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 9.2 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 9, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + } + ] + }, + { + "Id": 10, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 11, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 12, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 13, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 14, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 15, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 16, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 17, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 18, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 19, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 20, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + } + ] + }, + { + "Id": 22, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 23, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 24, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 25, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + } + ] + }, + { + "Id": 26, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + } + ] + }, + { + "Id": 27, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 28, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 29, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 30, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 31, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 32, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -5.96045E-09, + "Y": -2.4835207E-09, + "Z": 1 + }, + "D": 4.6566015E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 33, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + } + ] + }, + { + "Id": 34, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 35, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 36, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 37, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 38, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 39, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 40, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 41, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 42, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 43, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 44, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 45, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 46, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 47, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 48, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 49, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 50, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 51, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 52, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 53, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 54, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 55, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + } + ] + }, + { + "Id": 56, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 5.96045E-09, + "Y": -1.387775E-17, + "Z": -1 + }, + "D": -5.364405E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 368, + "PolygonId": 54, + "Flags": 1 + }, + { + "OtherCellId": 371, + "PolygonId": 55, + "Flags": 1 + }, + { + "OtherCellId": 373, + "PolygonId": 56, + "Flags": 1 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146354, + 2847146355, + 2847146356, + 2847146357 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40174.json b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40174.json new file mode 100644 index 00000000..a47a8941 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40174.json @@ -0,0 +1,583 @@ +{ + "CellId": 2847146356, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 3.999 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 2.98 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.664 + }, + "Vertices": [ + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 3.999 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 2.98 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.664 + }, + "Vertices": [ + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 373, + "PolygonId": 7, + "Flags": 1 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146353, + 2847146354, + 2847146355, + 2847146357 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40175.json b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40175.json new file mode 100644 index 00000000..49a53370 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B40175.json @@ -0,0 +1,413 @@ +{ + "CellId": 2847146357, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -7 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -7 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 5.96045E-09, + "Y": -1.387775E-17, + "Z": -1 + }, + "D": -5.364405E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 369, + "PolygonId": 4, + "Flags": 3 + }, + { + "OtherCellId": 372, + "PolygonId": 5, + "Flags": 3 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146353, + 2847146354, + 2847146355, + 2847146356 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/cellar-lip/wedge-records.jsonl b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/wedge-records.jsonl new file mode 100644 index 00000000..c5735e20 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/cellar-lip/wedge-records.jsonl @@ -0,0 +1,29 @@ +{"tick":3135,"timestampMs":262876276,"input":{"currentPos":{"x":153.73085,"y":9.728503,"z":93.43498},"targetPos":{"x":153.74791,"y":10.200278,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.74791,"y":10.200278,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.74791,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":153.74791,"y":10.200278,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":3143,"timestampMs":262876359,"input":{"currentPos":{"x":153.76514,"y":9.728503,"z":93.43498},"targetPos":{"x":153.78326,"y":10.229973,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.78326,"y":10.229973,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.78326,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.78326,"y":10.229973,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3163,"timestampMs":262876563,"input":{"currentPos":{"x":153.85286,"y":9.728503,"z":93.43498},"targetPos":{"x":153.8703,"y":10.211022,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.8703,"y":10.211022,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.8703,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.8703,"y":10.211022,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3186,"timestampMs":262876807,"input":{"currentPos":{"x":153.9604,"y":9.728503,"z":93.43498},"targetPos":{"x":153.97502,"y":10.133064,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.97502,"y":10.133064,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.97502,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":153.97502,"y":10.133064,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":3210,"timestampMs":262877045,"input":{"currentPos":{"x":154.0595,"y":9.728503,"z":93.43498},"targetPos":{"x":154.07689,"y":10.209565,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.07689,"y":10.209565,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":154.07689,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.07689,"y":10.209565,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3233,"timestampMs":262877276,"input":{"currentPos":{"x":154.15952,"y":9.728503,"z":93.43498},"targetPos":{"x":154.1759,"y":10.181853,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.1759,"y":10.181853,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":154.1759,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.1759,"y":10.181853,"z":93.43498},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.018063338,"w":0.99983686},"velocity":{"x":0.42796403,"y":11.840406,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3351,"timestampMs":262878460,"input":{"currentPos":{"x":153.4518,"y":9.728503,"z":93.43498},"targetPos":{"x":153.33388,"y":10.169506,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.33388,"y":10.169506,"z":93.43498},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.13027212,"w":0.99147826},"velocity":{"x":-3.0606577,"y":11.445992,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.4518,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":1,"y":-8.74228E-08,"z":0}},"bodyAfter":{"position":{"x":153.33388,"y":10.169506,"z":93.43498},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.13027212,"w":0.99147826},"velocity":{"x":-3.0606577,"y":11.445992,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":1,"y":-8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3371,"timestampMs":262878655,"input":{"currentPos":{"x":153.44606,"y":9.728503,"z":93.43498},"targetPos":{"x":153.43967,"y":10.24506,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.43967,"y":10.24506,"z":93.43498},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.006181737,"w":0.99998087},"velocity":{"x":-0.14648134,"y":11.847231,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.43967,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.43967,"y":10.24506,"z":93.43498},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.006181737,"w":0.99998087},"velocity":{"x":-0.14648134,"y":11.847231,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3402,"timestampMs":262878973,"input":{"currentPos":{"x":153.41031,"y":9.728503,"z":93.43498},"targetPos":{"x":153.40495,"y":10.161768,"z":93.43498},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.40495,"y":10.161768,"z":93.43498},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.006181737,"w":0.99998087},"velocity":{"x":-0.14648134,"y":11.847231,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":1,"y":-8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.41031,"y":9.728503,"z":93.43498},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.40495,"y":10.161768,"z":93.43498},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.006181737,"w":0.99998087},"velocity":{"x":-0.14648134,"y":11.847231,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3885,"timestampMs":262883615,"input":{"currentPos":{"x":153.57297,"y":9.823084,"z":93.51064},"targetPos":{"x":153.58644,"y":10.3026905,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.58644,"y":10.3026905,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.58644,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.58644,"y":10.3026905,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3909,"timestampMs":262883872,"input":{"currentPos":{"x":153.6587,"y":9.823084,"z":93.51064},"targetPos":{"x":153.67194,"y":10.29413,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.67194,"y":10.29413,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.67194,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":153.67194,"y":10.29413,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":3940,"timestampMs":262884181,"input":{"currentPos":{"x":153.76233,"y":9.823084,"z":93.51064},"targetPos":{"x":153.7749,"y":10.270569,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.7749,"y":10.270569,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.7749,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.7749,"y":10.270569,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":3967,"timestampMs":262884453,"input":{"currentPos":{"x":153.85223,"y":9.823084,"z":93.51064},"targetPos":{"x":153.86536,"y":10.289894,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.86536,"y":10.289894,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.86536,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.86536,"y":10.289894,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.014045194,"w":0.99990135},"velocity":{"x":0.33278593,"y":11.843463,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4018,"timestampMs":262884952,"input":{"currentPos":{"x":153.41425,"y":9.823084,"z":93.51064},"targetPos":{"x":153.34381,"y":10.280462,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.34381,"y":10.280462,"z":93.51064},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.076320924,"w":0.9970833},"velocity":{"x":-1.8032467,"y":11.710111,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.41425,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.34381,"y":10.280462,"z":93.51064},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.076320924,"w":0.9970833},"velocity":{"x":-1.8032467,"y":11.710111,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4022,"timestampMs":262884991,"input":{"currentPos":{"x":153.41425,"y":9.823084,"z":93.51064},"targetPos":{"x":153.3429,"y":10.28642,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.3429,"y":10.28642,"z":93.51064},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.076320924,"w":0.9970833},"velocity":{"x":-1.8032467,"y":11.710111,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.41425,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":1,"y":-8.74228E-08,"z":0}},"bodyAfter":{"position":{"x":153.3429,"y":10.28642,"z":93.51064},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.076320924,"w":0.9970833},"velocity":{"x":-1.8032467,"y":11.710111,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":1,"y":-8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4049,"timestampMs":262885262,"input":{"currentPos":{"x":153.49516,"y":9.823084,"z":93.51064},"targetPos":{"x":153.54453,"y":10.317957,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.54453,"y":10.317957,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.049692065,"w":0.9987646},"velocity":{"x":1.1760621,"y":11.789623,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.54453,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.54453,"y":10.317957,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.049692065,"w":0.9987646},"velocity":{"x":1.1760621,"y":11.789623,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4091,"timestampMs":262885677,"input":{"currentPos":{"x":153.9869,"y":9.823084,"z":93.51064},"targetPos":{"x":154.03308,"y":10.286115,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.03308,"y":10.286115,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.049692065,"w":0.9987646},"velocity":{"x":1.1760621,"y":11.789623,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":154.03308,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":154.03308,"y":10.286115,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.049692065,"w":0.9987646},"velocity":{"x":1.1760621,"y":11.789623,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":4193,"timestampMs":262886713,"input":{"currentPos":{"x":154.05077,"y":9.823084,"z":93.51064},"targetPos":{"x":154.09308,"y":10.273949,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.09308,"y":10.273949,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.04677716,"w":0.99890536},"velocity":{"x":1.1072311,"y":11.7962885,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":154.09308,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":154.09308,"y":10.273949,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.04677716,"w":0.99890536},"velocity":{"x":1.1072311,"y":11.7962885,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":4205,"timestampMs":262886828,"input":{"currentPos":{"x":154.17876,"y":9.823084,"z":93.51064},"targetPos":{"x":154.22104,"y":10.273495,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.22104,"y":10.273495,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.04677716,"w":0.99890536},"velocity":{"x":1.1072311,"y":11.7962885,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":154.22104,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.22104,"y":10.273495,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.04677716,"w":0.99890536},"velocity":{"x":1.1072311,"y":11.7962885,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4213,"timestampMs":262886906,"input":{"currentPos":{"x":154.26338,"y":9.823084,"z":93.51064},"targetPos":{"x":154.3069,"y":10.286709,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.3069,"y":10.286709,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.04677716,"w":0.99890536},"velocity":{"x":1.1072311,"y":11.7962885,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":154.3069,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.3069,"y":10.286709,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.04677716,"w":0.99890536},"velocity":{"x":1.1072311,"y":11.7962885,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4221,"timestampMs":262886985,"input":{"currentPos":{"x":154.35124,"y":9.823084,"z":93.51064},"targetPos":{"x":154.37279,"y":10.279013,"z":93.51064},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.37279,"y":10.279013,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.023612391,"w":0.99972117},"velocity":{"x":0.55936974,"y":11.834926,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":154.37279,"y":9.823084,"z":93.51064},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.37279,"y":10.279013,"z":93.51064},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.023612391,"w":0.99972117},"velocity":{"x":0.55936974,"y":11.834926,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4398,"timestampMs":262888732,"input":{"currentPos":{"x":153.56111,"y":9.389898,"z":93.16409},"targetPos":{"x":153.40527,"y":9.150686,"z":93.16409},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.40527,"y":9.150686,"z":93.16409},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":-3.85181,"y":-5.9124026,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.56111,"y":9.389898,"z":93.16409},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":1,"y":-8.74228E-08,"z":0}},"bodyAfter":{"position":{"x":153.40527,"y":9.150686,"z":93.16409},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":-3.85181,"y":-5.9124026,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":1,"y":-8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4531,"timestampMs":262889815,"input":{"currentPos":{"x":153.50339,"y":9.73499,"z":93.44017},"targetPos":{"x":153.51482,"y":10.191414,"z":93.44017},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.51482,"y":10.191414,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.51482,"y":9.73499,"z":93.44017},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.51482,"y":10.191414,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4547,"timestampMs":262889974,"input":{"currentPos":{"x":153.55008,"y":9.73499,"z":93.44017},"targetPos":{"x":153.56207,"y":10.213897,"z":93.44017},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.56207,"y":10.213897,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":153.56207,"y":9.73499,"z":93.44017},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.56207,"y":10.213897,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4780,"timestampMs":262892348,"input":{"currentPos":{"x":154.2535,"y":9.73499,"z":93.44017},"targetPos":{"x":154.2664,"y":10.250063,"z":93.44017},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.2664,"y":10.250063,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":154.2664,"y":9.73499,"z":93.44017},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.2664,"y":10.250063,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4809,"timestampMs":262892702,"input":{"currentPos":{"x":154.36081,"y":9.73499,"z":93.44017},"targetPos":{"x":154.37146,"y":10.160073,"z":93.44017},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.37146,"y":10.160073,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":154.37146,"y":9.73499,"z":93.44017},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.37146,"y":10.160073,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"contactPlaneCellId":2847146356,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":3.9713743E-08,"y":-0.62469506,"z":0.7808688},"d":-66.77794},"walkableVertices":[{"x":154.76901,"y":6.3531494,"z":90.6},{"x":154.76901,"y":9.853149,"z":93.4},{"x":153.069,"y":9.853149,"z":93.4},{"x":153.069,"y":6.3531494,"z":90.6}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":4844,"timestampMs":262893063,"input":{"currentPos":{"x":154.44443,"y":9.73499,"z":93.44017},"targetPos":{"x":154.45627,"y":10.207521,"z":93.44017},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.45627,"y":10.207521,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-1,"y":8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":154.44443,"y":9.73499,"z":93.44017},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":154.45627,"y":10.207521,"z":93.44017},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.012522013,"w":0.9999216},"velocity":{"x":0.29670182,"y":11.844422,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":7519,"timestampMs":262918465,"input":{"currentPos":{"x":154.42896,"y":9.811654,"z":93.5015},"targetPos":{"x":154.4983,"y":10.303496,"z":93.5015},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":154.4983,"y":10.303496,"z":93.5015},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.0699809,"w":0.99754834},"velocity":{"x":1.654221,"y":11.732089,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-1,"y":8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"walkableVertices":[{"x":154.929,"y":10.35315,"z":94},{"x":154.929,"y":6.353151,"z":94},{"x":157.829,"y":6.3531504,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":154.42896,"y":9.811654,"z":93.5015},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-1,"y":8.74228E-08,"z":0}},"bodyAfter":{"position":{"x":154.4983,"y":10.303496,"z":93.5015},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.0699809,"w":0.99754834},"velocity":{"x":1.654221,"y":11.732089,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-1,"y":8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"walkableVertices":[{"x":154.929,"y":10.35315,"z":94},{"x":154.929,"y":6.353151,"z":94},{"x":157.829,"y":6.3531504,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} +{"tick":9466,"timestampMs":262936723,"input":{"currentPos":{"x":153.4952,"y":9.751367,"z":93.45327},"targetPos":{"x":153.44092,"y":10.214444,"z":93.45327},"cellId":2847146357,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":153.44092,"y":10.214444,"z":93.45327},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.05830996,"w":0.9982985},"velocity":{"x":-1.3793778,"y":11.767569,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":1,"y":-8.74228E-08,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0},"result":{"position":{"x":153.4952,"y":9.751367,"z":93.45327},"cellId":2847146357,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":-8.74228E-08,"y":-1,"z":0}},"bodyAfter":{"position":{"x":153.44092,"y":10.214444,"z":93.45327},"orientation":{"isIdentity":false,"x":0,"y":0,"z":0.05830996,"w":0.9982985},"velocity":{"x":-1.3793778,"y":11.767569,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":-8.74228E-08,"y":-1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"contactPlaneCellId":2847146357,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":5.9604504E-09,"y":2.4835205E-09,"z":1},"d":-94},"walkableVertices":[{"x":152.929,"y":15.153151,"z":94},{"x":152.929,"y":10.353151,"z":94},{"x":154.929,"y":10.35315,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} diff --git a/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs new file mode 100644 index 00000000..fc6423b3 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// P2 cellar-LIP wedge (2026-06-04) — deterministic reproduction of the +/// "blocked at the last step" wedge at the Holtburg cottage cellar lip, +/// distinct from the earlier issue-#98 cellar (cells 0xA9B4014X). +/// +/// +/// Built from live captures this session: +/// +/// Retail-connector trace (CEnvCell::find_collisions) proved +/// retail's connector cell 0xA9B40175 NEVER blocks (2692 OK + +/// 94 Adjusted + 0 Collided + 0 Slid over ~85K samples). +/// acdream live capture at the wedge: the player (r=0.48 body +/// sphere) is mid-climb at world Z=93.936, carried in the 0.364 m-tall +/// threshold slab 0xA9B40175. Its 0.48 sphere grazes the slab's +/// −X wall (local X=9; sphere at X=8.523 reaches 9.003 — a 3 mm graze) +/// → StepSphereUpDoStepUp fails (no CP on the flat +/// cottage floor) → StepUpSlide returns Collided → the +/// per-cell collide returns Collided → wedge. +/// +/// +/// +/// +/// Fixtures are real cell dumps (ACDREAM_DUMP_CELLS) of the three lip +/// cells: 0xA9B40171 (cottage floor), 0xA9B40174, 0xA9B40175 +/// (threshold connector). The BSP=null hydration gap is bridged with a +/// synthetic single-leaf BSP, same as . +/// +/// +/// +/// RED status: the climb-advance assertion is expected to FAIL while the +/// wedge exists (the player freezes at the threshold ~Z=93.94 instead of +/// reaching the cottage floor ~Z=94.48). When the fix lands it flips to GREEN. +/// +/// +public class CellarLipWedgeTests +{ + private const uint CottageFloorId = 0xA9B40171u; // cottage room floor + private const uint Connector74Id = 0xA9B40174u; + private const uint ThresholdId = 0xA9B40175u; // 0.364 m threshold slab + + // Player physics from PlayerMovementController.cs (human, from Setup). + private const float SphereRadius = 0.48f; + private const float SphereHeight = 1.20f; + private const float StepUpHeight = 0.40f; + private const float StepDownHeight = 0.04f; + + // The live-captured wedge state. The probe's [indoor-bsp] wpos is the + // FOOT-SPHERE CENTER (153.406, 9.754, 93.936); the engine body Position is + // the foot BOTTOM = center − radius = Z 93.456. Player carried in the + // 0.364 m threshold slab 0xA9B40175, climbing the cellar stairs; observed + // motion at the lip is world −Y (into the cottage). + private static readonly Vector3 WedgeSphereCenter = new(153.406f, 9.754f, 93.936f); + private static readonly Vector3 WedgeBodyPos = + new(153.406f, 9.754f, 93.936f - SphereRadius); // foot bottom Z=93.456 + private static readonly Vector3 PerTickOffset = new(0f, -0.10f, 0f); + + private const float CottageFloorZ = 94.00f; + private const float RestOnCottageZ = CottageFloorZ + SphereRadius; // ≈94.48 + + /// + /// Diagnostic: drive the player off the threshold toward the cottage and + /// dump the trajectory + indoor-BSP/step probes. Always passes; the + /// captured stdout shows exactly what the engine does each tick. + /// + [Fact] + public void Diagnostic_DriveOffThreshold_DumpTrajectory() + { + PhysicsDiagnostics.ProbeResolveEnabled = true; + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + var saved = Console.Out; + var sw = new StringWriter(); + Console.SetOut(sw); + try + { + var engine = BuildEngineWithLipFixtures(); + var body = BuildWedgeBody(); + var traj = SimulateTicks(engine, body, ThresholdId, 4); + + Console.SetOut(saved); + var probeLines = sw.ToString(); + File.WriteAllText( + Path.Combine(Path.GetTempPath(), "lip-wedge-diag.log"), + "TRAJECTORY:\n" + string.Join("\n", traj.Select(p => + $"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " + + $"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}")) + + "\n\nPROBES:\n" + probeLines); + Assert.True(true); + } + finally + { + Console.SetOut(saved); + PhysicsDiagnostics.ProbeResolveEnabled = false; + PhysicsDiagnostics.ProbeIndoorBspEnabled = false; + } + } + + /// + /// DOCUMENTS-THE-BUG (passes while the wedge exists; FAILS when the fix + /// lands). Seeded at the live wedge position (foot-sphere center Z=93.936, + /// carried in the 0.364 m threshold slab 0xA9B40175) and driven forward, + /// the player FREEZES — blocked by the slab's −X side wall (poly normal + /// world (1,0,0)) — instead of advancing onto the cottage floor. Retail's + /// 0175 never blocks (live CEnvCell::find_collisions trace: 0 Collided/Slid). + /// + /// + /// Faithfulness caveat: the per-tick drive direction (world −Y) is an + /// approximation — the exact targetPos would come from an + /// ACDREAM_CAPTURE_RESOLVE JSONL of the live wedge. The −Y drive is + /// PARALLEL to the −X wall, so it reproduces the block but may not be the + /// real climb path. A candidate fix (replacing the A6.P4 neg-poly + /// "return Collided" shortcut with retail's slide_sphere) did NOT clear this: + /// the slide returns Slid with offset=0 (displacement already along the + /// crease), the loop re-checks with gDelta≈0 → SlideSphere's + /// offset.LengthSquared<ε → Collided branch → revert. The real fix needs a + /// faithful repro + the slide/loop-commit investigation (see the handoff + /// doc 2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md, Correction 2). + /// + /// + /// When the wedge is fixed the player advances and this assertion FAILS — + /// that is the signal to flip it to assert the climb. + /// + [Fact] + public void DocumentsWedge_PlayerFrozenAtThreshold_BlockedByMinusXWall() + { + var engine = BuildEngineWithLipFixtures(); + var body = BuildWedgeBody(); + var traj = SimulateTicks(engine, body, ThresholdId, 30); + + var final = traj[^1]; + float yAdvance = WedgeBodyPos.Y - final.Position.Y; // +ve = moved into cottage + float zRise = final.Position.Z - WedgeBodyPos.Z; // +ve = climbed + + Assert.True( + yAdvance < 0.1f && zRise < 0.1f, + $"DOCUMENTS-THE-BUG: expected the player to be FROZEN at the threshold " + + $"(the −X-wall wedge). Instead it advanced to " + + $"({final.Position.X:F3},{final.Position.Y:F3},{final.Position.Z:F3}) " + + $"after 30 ticks (yAdvance={yAdvance:F3}, zRise={zRise:F3}). If the wedge " + + $"fix landed, FLIP this assertion to require the climb " + + $"(Z≥{CottageFloorZ - 0.05f:F2}, yAdvance>0.5)."); + } + + // ───────────────────────────── helpers ───────────────────────────── + + private static PhysicsBody BuildWedgeBody() => new() + { + Position = WedgeBodyPos, // foot bottom Z=93.456 + Orientation = Quaternion.Identity, + + // Best-effort grounded seed: a flat floor at foot level so the player + // starts "on the ground" mid-climb (the exact body-before state would + // come from an ACDREAM_CAPTURE_RESOLVE JSONL; this approximation puts + // the foot-sphere center at Z=93.936 — the live wedge — so the + // geometric −X-wall full-hit fires). + ContactPlaneValid = true, + ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -WedgeBodyPos.Z), + ContactPlaneCellId = ThresholdId, + WalkablePolygonValid = true, + WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -WedgeBodyPos.Z), + WalkableVertices = new[] + { + new Vector3(WedgeBodyPos.X - 1f, WedgeBodyPos.Y - 1f, WedgeBodyPos.Z), + new Vector3(WedgeBodyPos.X - 1f, WedgeBodyPos.Y + 1f, WedgeBodyPos.Z), + new Vector3(WedgeBodyPos.X + 1f, WedgeBodyPos.Y + 1f, WedgeBodyPos.Z), + new Vector3(WedgeBodyPos.X + 1f, WedgeBodyPos.Y - 1f, WedgeBodyPos.Z), + }, + WalkableUp = Vector3.UnitZ, + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + + private static List SimulateTicks( + PhysicsEngine engine, PhysicsBody body, uint initialCellId, int tickCount) + { + uint cellId = initialCellId; + bool isOnGround = true; + var traj = new List { new(0, body.Position, cellId, isOnGround, body.ContactPlaneValid) }; + + for (int tick = 1; tick <= tickCount; tick++) + { + Vector3 target = body.Position + PerTickOffset; + var result = engine.ResolveWithTransition( + currentPos: body.Position, + targetPos: target, + cellId: cellId, + sphereRadius: SphereRadius, + sphereHeight: SphereHeight, + stepUpHeight: StepUpHeight, + stepDownHeight: StepDownHeight, + isOnGround: isOnGround, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0); + + body.Position = result.Position; + cellId = result.CellId; + isOnGround = result.IsOnGround; + traj.Add(new(tick, body.Position, cellId, isOnGround, body.ContactPlaneValid)); + } + return traj; + } + + private sealed record TrajPoint(int Tick, Vector3 Position, uint CellId, bool IsOnGround, bool CpValid); + + // ─────────────────────── faithful live-wedge replay ─────────────────────── + // Replays a captured wedge ResolveWithTransition call (exact currentPos / + // targetPos / body-before from ACDREAM_CAPTURE_RESOLVE at the live cellar + // lip) through the lip-cell engine. The live climb direction is −X,+Y (the + // synthetic −Y guess was backwards). 29 representative wedge records are in + // Fixtures/cellar-lip/wedge-records.jsonl. + + private static readonly System.Text.Json.JsonSerializerOptions WedgeJsonOptions = + new() { IncludeFields = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; + + private static List LoadWedgeRecords() + { + var path = Path.Combine(FixtureDir, "wedge-records.jsonl"); + Assert.True(File.Exists(path), $"Wedge fixture missing: {path}"); + var list = new List(); + foreach (var line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + list.Add(System.Text.Json.JsonSerializer.Deserialize(line, WedgeJsonOptions)!); + } + return list; + } + + private static PhysicsBody SeedBody(PhysicsBodySnapshot s) => new() + { + Position = s.Position, + Orientation = s.Orientation, + Velocity = s.Velocity, + Acceleration = s.Acceleration, + Omega = s.Omega, + GroundNormal = s.GroundNormal, + SlidingNormal = s.SlidingNormal, + ContactPlaneValid = s.ContactPlaneValid, + ContactPlane = s.ContactPlane, + ContactPlaneCellId = s.ContactPlaneCellId, + ContactPlaneIsWater = s.ContactPlaneIsWater, + WalkablePolygonValid = s.WalkablePolygonValid, + WalkablePlane = s.WalkablePlane, + WalkableVertices = s.WalkableVertices, + WalkableUp = s.WalkableUp, + Elasticity = s.Elasticity, + Friction = s.Friction, + State = (PhysicsStateFlags)s.State, + TransientState = (TransientStateFlags)s.TransientState, + LastUpdateTime = s.LastUpdateTime, + }; + + private static (Vector3 res, float requested, float advance) ReplayRecord(ResolveCaptureRecord rec) + { + var engine = BuildEngineWithLipFixtures(); + var body = SeedBody(rec.BodyBefore!); + var result = engine.ResolveWithTransition( + currentPos: rec.Input.CurrentPos, + targetPos: rec.Input.TargetPos, + cellId: rec.Input.CellId, + sphereRadius: rec.Input.SphereRadius, + sphereHeight: rec.Input.SphereHeight, + stepUpHeight: rec.Input.StepUpHeight, + stepDownHeight: rec.Input.StepDownHeight, + isOnGround: rec.Input.IsOnGround, + body: body, + moverFlags: (ObjectInfoState)rec.Input.MoverFlags, + movingEntityId: rec.Input.MovingEntityId); + float requested = Vector3.Distance(rec.Input.CurrentPos, rec.Input.TargetPos); + float advance = Vector3.Distance(rec.Input.CurrentPos, result.Position); + return (result.Position, requested, advance); + } + + /// + /// Diagnostic: replay every captured wedge record and report advance% — to + /// confirm the lip-cell engine reproduces the live stuck (0% advance) before + /// asserting a fix. Always passes; results in the message + %TEMP% file. + /// + [Fact] + public void Diagnostic_ReplayLiveWedgeRecords_Advance() + { + var recs = LoadWedgeRecords(); + var lines = new List(); + foreach (var (rec, i) in recs.Select((r, i) => (r, i))) + { + if (rec.BodyBefore is null) continue; + var (res, req, adv) = ReplayRecord(rec); + var cpN = rec.BodyBefore.ContactPlane.Normal; + lines.Add($"#{i} cp=({cpN.X:F2},{cpN.Y:F2},{cpN.Z:F2}) req={req:F3} adv={adv:F3} ({(req>0?100*adv/req:0):F0}%) " + + $"cur=({rec.Input.CurrentPos.X:F2},{rec.Input.CurrentPos.Y:F2},{rec.Input.CurrentPos.Z:F2}) " + + $"tgt=({rec.Input.TargetPos.X:F2},{rec.Input.TargetPos.Y:F2},{rec.Input.TargetPos.Z:F2}) " + + $"res=({res.X:F2},{res.Y:F2},{res.Z:F2})"); + } + File.WriteAllText(Path.Combine(Path.GetTempPath(), "lip-wedge-replay.log"), string.Join("\n", lines)); + Assert.True(true, string.Join("\n", lines.Take(10))); + } + + /// + /// Diagnostic: replay ONE floor-CP wedge record with the step-up + indoor + /// probes on, capturing why the step-up fails. Output to %TEMP%/lip-wedge-stepup.log. + /// + [Fact] + public void Diagnostic_ReplayFloorCpRecord_StepUpProbes() + { + var rec = LoadWedgeRecords().First(r => r.BodyBefore is not null + && r.BodyBefore.ContactPlane.Normal.Z > 0.99f); + var saved = Console.Out; + var sw = new StringWriter(); + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + PhysicsDiagnostics.ProbeStepWalkEnabled = true; + Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1"); + Console.SetOut(sw); + try + { + var (res, req, adv) = ReplayRecord(rec); + Console.SetOut(saved); + File.WriteAllText(Path.Combine(Path.GetTempPath(), "lip-wedge-stepup.log"), + $"record cur=({rec.Input.CurrentPos.X:F4},{rec.Input.CurrentPos.Y:F4},{rec.Input.CurrentPos.Z:F4}) " + + $"tgt=({rec.Input.TargetPos.X:F4},{rec.Input.TargetPos.Y:F4},{rec.Input.TargetPos.Z:F4}) " + + $"req={req:F3} adv={adv:F3} res=({res.X:F4},{res.Y:F4},{res.Z:F4})\n\n" + sw.ToString()); + Assert.True(true); + } + finally + { + Console.SetOut(saved); + Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null); + PhysicsDiagnostics.ProbeIndoorBspEnabled = false; + PhysicsDiagnostics.ProbeStepWalkEnabled = false; + } + } + + /// + /// FAITHFUL documents-the-bug (passes while the wedge exists; FAILS when the + /// fix lands → flip to assert the climb). Replays a captured FLOOR-contact- + /// plane wedge through the lip-cell engine; the player is STUCK (0% advance). + /// + /// + /// Root cause (traced via Diagnostic_ReplayFloorCpRecord_StepUpProbes): + /// the player is at the doorway EDGE of the cottage floor (0171, poly 0x0023, + /// Z=94). The step-up's step-down finds that floor and the 0.48 sphere + /// OVERLAPS it (0.085 m below), but acdream's walkable check REJECTS it + /// because the sphere center projects outside the floor poly's edge + /// (insideEdges=False, gap=−0.395) → no contact plane → step-up + /// fails → StepUpSlide=Collided. Retail accepts the floor at its edge and + /// crosses (0175 never blocks). Fix is in the walkable-edge acceptance + /// (WalkableHitsSphere / PolygonHitsSpherePrecise / CheckWalkable edge math) + /// — compare retail CPolygon::walkable_hits_sphere + check_walkable. DOOR + /// REGRESSION RISK: walkable changes are global; visual-gate + door tests. + /// + /// + [Fact] + public void DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge() + { + var recs = LoadWedgeRecords(); + var rec = recs.First(r => r.BodyBefore is not null + && r.BodyBefore.ContactPlane.Normal.Z > 0.99f); + var (res, requested, advance) = ReplayRecord(rec); + var c = rec.Input.CurrentPos; var t = rec.Input.TargetPos; + Assert.True(advance < 0.1f * requested, + $"DOCUMENTS-THE-BUG: expected the player STUCK at the cottage-floor edge. " + + $"Instead it advanced: cur=({c.X:F3},{c.Y:F3},{c.Z:F3}) tgt=({t.X:F3},{t.Y:F3},{t.Z:F3}) " + + $"res=({res.X:F3},{res.Y:F3},{res.Z:F3}) requested={requested:F3} advance={advance:F3}. " + + $"If the walkable-edge fix landed, FLIP this to require advance>0.25·requested."); + } + + private static PhysicsEngine BuildEngineWithLipFixtures() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + foreach (var cellId in new[] { CottageFloorId, Connector74Id, ThresholdId }) + { + var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json"); + Assert.True(File.Exists(path), $"Lip fixture missing: {path}"); + var dump = CellDumpSerializer.Read(path); + var cell = CellDumpSerializer.Hydrate(dump); + cache.RegisterCellStructForTest(cellId, AttachSyntheticBsp(cell)); + } + + // Empty-terrain landblock so FindObjCollisions' TryGetLandblockContext + // succeeds at the lip XY (X≈153, Y≈9). Flat far-below surface; the + // indoor BSP path fires first so terrain is never consulted. + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock( + landblockId: 0xA9B40000u, + terrain: new TerrainSurface(heights, heightTable), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + return engine; + } + + private static CellPhysics AttachSyntheticBsp(CellPhysics cell) + { + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 0f), Radius = 15f }, + }; + foreach (var kv in cell.Resolved) + leaf.Polygons.Add(kv.Key); + + return new CellPhysics + { + BSP = new PhysicsBSPTree { Root = leaf }, + PhysicsPolygons = cell.PhysicsPolygons, + Vertices = cell.Vertices, + WorldTransform = cell.WorldTransform, + InverseWorldTransform = cell.InverseWorldTransform, + Resolved = cell.Resolved, + CellBSP = cell.CellBSP, + Portals = cell.Portals, + PortalPolygons = cell.PortalPolygons, + VisibleCellIds = cell.VisibleCellIds, + }; + } + + private static string FixtureDir => + Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests", "Fixtures", "cellar-lip"); + + private static string SolutionRoot() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) + return dir; + dir = Path.GetDirectoryName(dir); + } + throw new InvalidOperationException("Could not locate solution root (AcDream.slnx)."); + } +} diff --git a/tools/cdb/retail-connector-collide-trace.cdb b/tools/cdb/retail-connector-collide-trace.cdb new file mode 100644 index 00000000..a4684191 --- /dev/null +++ b/tools/cdb/retail-connector-collide-trace.cdb @@ -0,0 +1,34 @@ +$$ Retail per-cell collide trace (2026-06-04 — the DECISIVE discriminator). +$$ +$$ Question: when retail crosses the cellar lip, what does the per-cell collide +$$ CEnvCell::find_collisions RETURN for the connector cell 0xA9B40175 and the floor +$$ cell 0xA9B40171 (and 0174)? This is the thing check_other_cells calls per cell +$$ (vtable[0x88]). enum: 1=OK 2=COLLIDED 3=ADJUSTED 4=SLID. +$$ +$$ - If 0175 returns mostly 1 (OK) -> acdream's connector Slid is SPURIOUS +$$ (acdream over-detects / over-steps-up 0175). Fix = stop acdream blocking 0175. +$$ - If 0175 returns 4 (SLID) too -> retail slides+continues (no revert); +$$ acdream's wedge is the substep REVERT, not the collide. Look upstream. +$$ +$$ ROBUST BY CONSTRUCTION (verified offline via `cdb -z acclient.exe`): +$$ CEnvCell::find_collisions @0x52c100 has a SINGLE exit at +0x1e (0x52c11e): +$$ 0052c11e pop edi <-- esi still = this (CEnvCell*), eax = result +$$ 0052c11f pop esi +$$ 0052c120 ret 4 +$$ cell id = poi(esi+0x28) (CEnvCell.m_DID). No `gu`, no `qd` in the action. +$$ Reading this+result directly at the single exit is nesting-safe. +$$ +$$ STEP 0 (optional re-verify): the `uf` below re-dumps the function; confirm the +$$ single exit is still at +0x1e and esi=this there, before driving. +$$ +$$ Close retail when done to detach cdb (debuggee exit detaches; no qd needed). +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\retail-connector-collide-trace.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +uf acclient!CEnvCell::find_collisions +$$ Log EVERY return for the lip cluster 0xA9B4017X (0171 floor / 0174 / 0175 connector). +$$ Also log any NON-OK (ret!=1) return for ANY Holtburg cell 0xA9B4xxxx (catch blocks +$$ outside the 017X cluster). Low volume; cheap action + gc. +bp acclient!CEnvCell::find_collisions+0x1e ".if ((poi(@esi+0x28) & 0xFFFFFFF0) == 0xA9B40170) { .printf \"lip cell=0x%x ret=%d\\n\", poi(@esi+0x28), @eax } .elsif (((poi(@esi+0x28) & 0xFFFF0000) == 0xA9B40000) & (@eax != 1)) { .printf \"blk cell=0x%x ret=%d\\n\", poi(@esi+0x28), @eax }; gc" +g diff --git a/tools/cdb/retail-flatfloor-trace.cdb b/tools/cdb/retail-flatfloor-trace.cdb new file mode 100644 index 00000000..30bee286 --- /dev/null +++ b/tools/cdb/retail-flatfloor-trace.cdb @@ -0,0 +1,26 @@ +$$ Retail flat-floor contact-plane trace (2026-06-04, v2 — CORRECTED). +$$ Decisive question: when retail lands on the FLAT cottage floor during the climb, does +$$ BSPTREE::step_sphere_down SET the contact plane (return 3) or NOT (return 1)? +$$ +$$ v1 (gu-in-bp-action) FAILED: "commands skipped ... target execution inside an event +$$ handler" corrupted eax -> a perfect 1,3,1,3 alternation artifact. DO NOT use `gu` in a +$$ bp action. v2: stash the carried cell in $t3 at ENTRY (arg2 = sphere_path at [esp+4], +$$ before the prologue), then break at the TWO RETURN addresses and print SET/NO + $t3. +$$ +$$ STEP 0 (do this FIRST, before driving): the +0x218 (return 3) / +0x227 (return 1) offsets +$$ below are from the decomp (step_sphere_down @0x53a210; return 3 @0x53a428; return 1 @0x53a437) +$$ and MUST be verified against the live binary. After attaching, read the log: the `u` output +$$ (below) disassembles the function — confirm which addresses load eax=3 vs eax=1 (or jmp to the +$$ shared epilogue) and FIX the two `bp ...+0xNNN` offsets if they differ, then re-attach. +$$ +$$ No qd / no Stop-Process needed if the user closes retail (debuggee exit detaches cdb). +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\retail-flatfloor-trace.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +u acclient!BSPTREE::step_sphere_down L90 +r @$t3 = 0 +bp acclient!BSPTREE::step_sphere_down "r @$t3 = @@c++(((acclient!SPHEREPATH *)poi(@esp+4))->check_pos.objcell_id); gc" +bp acclient!BSPTREE::step_sphere_down+0x218 ".printf \"SET-CP cell=0x%x\\n\", @$t3; gc" +bp acclient!BSPTREE::step_sphere_down+0x227 ".printf \"NO-CP cell=0x%x\\n\", @$t3; gc" +g diff --git a/tools/cdb/retail-lip-trace.cdb b/tools/cdb/retail-lip-trace.cdb new file mode 100644 index 00000000..92280151 --- /dev/null +++ b/tools/cdb/retail-lip-trace.cdb @@ -0,0 +1,13 @@ +$$ Retail cellar-lip trace (2026-06-04). Captures, per CTransition::step_up, +$$ the CARRIED cell (sphere_path.check_pos.objcell_id) + world position +$$ (check_pos.frame.m_fOrigin). Discriminates whether retail's carried cell +$$ STAYS STABLE at the cellar lip (-> acdream's mid-step-up cell-flip is the bug) +$$ or ALTERNATES like acdream (-> the connector-cell slide is the bug). +$$ Auto-detaches (qd) after 150 step_ups so retail keeps running. +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\retail-lip-trace.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe +r @$t0 = 0 +bp acclient!CTransition::step_up "r @$t0 = @$t0 + 1; .printf \"--- stepup #%d ---\\n\", @$t0; dt acclient!CTransition @ecx sphere_path.check_pos.objcell_id sphere_path.check_pos.frame.m_fOrigin.x sphere_path.check_pos.frame.m_fOrigin.y sphere_path.check_pos.frame.m_fOrigin.z; .if (@$t0 >= 150) { .printf \"=== DETACH after %d step_ups ===\\n\", @$t0; qd } .else { gc }" +g From cc4590f9e52e91aa0902246e92f95e859dfb8df6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 09:15:19 +0200 Subject: [PATCH 009/172] =?UTF-8?q?fix(p2):=20cellar-lip=20wedge=20?= =?UTF-8?q?=E2=80=94=20check=5Fother=5Fcells=20must=20use=20the=20LIVE=20s?= =?UTF-8?q?phere=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "blocked at the last cellar step" wedge (the primary, ramp-climb family — 20/29 captured records). The prior session's pinned "find_walkable is never called during the step-down" was a probe artifact: a fresh [fc-dispatch]/[step-sphere-down] trace proves Path-3 StepSphereDown IS reached for both the carried cell and the iterated other-cell. The real divergence is in Transition.CheckOtherCells. Retail's check_other_cells (acclient_2013_pseudo_c.txt:272735 → (*cell+0x88)(this)) re-collides the OTHER cells against the LIVE sphere_path.global_sphere — the position AFTER the primary insert_into_cell ran. The primary collide can MOVE the sphere: a Path-5 full-hit dispatches step_sphere_up, and a successful step-up climbs the foot onto the cottage floor yet still returns OK. acdream instead reused a footCenter snapshot captured BEFORE the primary collide, so once the lip-riser step-up climbed the foot onto the floor, check_other_cells still queried 0171 at the pre-climb (sunk ~0.25 m below the floor) position → the foot spuriously near-missed the very floor it had climbed onto → neg_step_up → a doomed second step_up vs the floor normal (0,0,1) whose step_up_slide unwound the climb → validate_transition reverted → 0% advance. Fix: re-read footCenter = sp.GlobalSphere[0].Origin at the top of RunCheckOtherCellsAndAdvance (one line). Pre-fix 0/29 wedge records advanced; post-fix 20/29 climb onto Z≈94. No regression: full Core suite 1321 pass / 4 fail (the documented baseline: Apparatus_Grounded_50cmOffCenter, 2× DoorBugTrajectoryReplay LiveCompare_*, BSPStepUpTests.D4) / 1 skip. The 2 door LiveCompare divergences are byte-identical with/without the fix (the door's step_up FAILS → sphere restored → position unchanged → footCenter == live). Tests: CellarLipWedgeTests.Fix_StaleFootCenter_RampRecordClimbsCottageFloor + Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance (new, GREEN). DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY documents the remaining 9/29 (0,-1,0)-sliding-normal +Y-kill family (slide territory, deferred to the visual gate). Apparatus retained (gated on ACDREAM_PROBE_INDOOR_BSP): [fc-dispatch] in BSPQuery.FindCollisions + [step-sphere-down] in BSPQuery.StepSphereDown + CellarLipWedgeTests.Diagnostic_TraceRecordByIndex — strip once the residual is resolved. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-04-p2-cellar-lip-flatfloor-cp-handoff.md | 50 ++++++- src/AcDream.Core/Physics/BSPQuery.cs | 28 ++++ src/AcDream.Core/Physics/TransitionTypes.cs | 17 +++ .../Physics/CellarLipWedgeTests.cs | 131 +++++++++++++++--- 4 files changed, 208 insertions(+), 18 deletions(-) diff --git a/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md b/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md index 1d430a25..a5576c11 100644 --- a/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md +++ b/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md @@ -1,6 +1,54 @@ # P2 cellar-lip wedge — CANONICAL handoff (flat-floor contact-plane coin-flip) -## ▶ NEXT-SESSION KICKOFF (START HERE — supersedes everything below) +## ✅ PRIMARY ROOT CAUSE FOUND + FIXED 2026-06-05 (START HERE — supersedes UPDATE 2 below) + +**The pinned "find_walkable is NEVER called during the step-down" (UPDATE 2) was a PROBE ARTIFACT.** +A clean `[fc-dispatch]`/`[step-sphere-down]` trace (TEMP probes, gated on `ACDREAM_PROBE_INDOOR_BSP`, +in `BSPQuery.FindCollisions` + `StepSphereDown`) proved `find_walkable` (Path 3 / `StepSphereDown`) +**IS** reached for both 0175 (primary) and 0171 (other-cell) during the step-down — UPDATE 2 mis-read +it (the `[fc-dispatch]` cell logs `path.CheckCellId` = the carried cell 0175 even while iterating +0171's BSP, because `CheckCellId` is the carried cell, not the iterated one). + +**THE REAL ROOT CAUSE (ramp-climb family, 20/29 records):** `Transition.CheckOtherCells` collided the +OTHER cells against a **stale `footCenter`** snapshotted at `FindEnvCollisions` entry (TransitionTypes.cs +~L1959) — i.e. BEFORE the primary `insert_into_cell` ran. The primary collide can MOVE the sphere: a +Path-5 full-hit dispatches `step_sphere_up`, and a successful step-up **climbs the foot onto the cottage +floor yet still returns OK**. Retail's `check_other_cells` (`acclient_2013_pseudo_c.txt:272735` → +`(*cell+0x88)(this)`) reads the **LIVE `sphere_path.global_sphere`** (post-insert). acdream used the +pre-climb snapshot, which is sunk ~0.25 m below the floor → the foot spuriously **near-misses the very +floor it just climbed onto** → `neg_step_up` → a doomed SECOND step_up against the floor normal (0,0,1) +whose `step_up_slide` unwinds the climb (it slides relative to `GlobalCurrCenter` = the step start, low Z) +→ `validate_transition` reverts the whole step → **0 % advance**. + +**FIX (shipped):** in `Transition.RunCheckOtherCellsAndAdvance` re-read `footCenter = +sp.GlobalSphere[0].Origin` before iterating other cells. One line + comment. Pre-fix 0/29 records +advanced; post-fix **20/29 climb onto the cottage floor (Z≈94)**. **Zero regression** — full Core suite +1321 pass / 4 fail (the documented baseline 4: `Apparatus_Grounded_50cmOffCenter`, +2× `DoorBugTrajectoryReplay LiveCompare_*`, `BSPStepUpTests.D4`) / 1 skip. The 2 door `LiveCompare` +divergences are **byte-identical** with/without the fix (the door's step_up FAILS → sphere restored → +position unchanged → `footCenter` == live). Tests: `CellarLipWedgeTests.Fix_StaleFootCenter_*` (2 new, +GREEN). + +**REMAINING RESIDUAL (9/29, OUT OF SCOPE this pass):** the `(0,-1,0)` sliding-normal **+Y-kill** +(`AdjustOffset` slide-crease projects the into-cottage +Y onto the floor×wall crease = world X and zeroes +it → only −X survives → hits the slab −X wall → step_up fails on the flat floor → revert). 7/29 records; +record #6 is the canonical one (`DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY`). This is +**slide-recovery territory** the kickoff said NOT to re-investigate, and is **suspected to be a +buggy-trajectory artifact** (the stale slide accumulated only because the player was already oscillating; +once the ramp-climb advances cleanly the player should not enter the south-wall-slide-into-doorway state). +**Let the VISUAL GATE decide** whether it needs a follow-up before touching the slide. (Record #21 moves +−Y away from the cottage and is likely a legitimate non-advance.) + +**VISUAL GATE (next):** run the client, walk up the Holtburg cottage cellar stairs — expect the last-step +wedge GONE (smooth ascent onto the floor). Also re-confirm the inn door BLOCKS and a generic step-up +climbs (the fix only changes `check_other_cells`'s position reference). If the ascent still intermittently +wedges, the `(0,-1,0)` +Y-kill is live → investigate `AdjustOffset` slide-crease / the sliding-normal seed +(with the visual evidence then justifying touching the slide). Apparatus: `[fc-dispatch]`/`[step-sphere-down]` +probes + `CellarLipWedgeTests.Diagnostic_TraceRecordByIndex` reproduce any record in <200 ms. + +--- + +## ▶ NEXT-SESSION KICKOFF (historical — its "find_walkable never called" framing was disproven above) **State:** M1.5 / P2 cellar-lip "blocked at the last step" wedge. A FAITHFUL deterministic reproduction now exists. The cause has been peeled through SIX evidence-disproven framings to one bounded question. No fix diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 54007754..cafb0616 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1236,9 +1236,20 @@ public static class BSPQuery ResolvedPolygon? polyHit = null; ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it + // TEMP diagnostic (cellar-lip wedge dispatch trace, 2026-06-05): Path 3 + // reached — log the step-down probe inputs + the walkable-finder result so + // we can see whether the cottage floor is tested + accepted. STRIP after fix. + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[step-sphere-down] ENTER cell=0x{path.CheckCellId:X8} stepDownAmt={path.StepDownAmt:F3} walkInterp={path.WalkInterp:F3} move=({movement.X:F3},{movement.Y:F3},{movement.Z:F3}) center=({checkPos.Center.X:F3},{checkPos.Center.Y:F3},{checkPos.Center.Z:F3}) r={checkPos.Radius:F3}")); + FindWalkableInternal(root, resolved, path, validPos, movement, up, ref polyHit, ref _polyId, ref changed); + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[step-sphere-down] RESULT cell=0x{path.CheckCellId:X8} changed={changed} poly={(polyHit is null ? "n/a" : $"0x{polyHit.Id:X4} n=({polyHit.Plane.Normal.X:F3},{polyHit.Plane.Normal.Y:F3},{polyHit.Plane.Normal.Z:F3})")}")); + if (changed && polyHit is not null) { // ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale @@ -1707,6 +1718,23 @@ public static class BSPQuery returnState: -1); } + // TEMP diagnostic (cellar-lip wedge dispatch trace, 2026-06-05): which of + // the 6 paths does this cell take? The path is flag-driven (BSP-independent), + // so the synthetic-leaf test reproduces it faithfully. Deduce the path from + // the dispatch order so a single line names path + every gating flag. + // Gated on ACDREAM_PROBE_INDOOR_BSP. STRIP once the wedge fix lands. + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + int _p = (path.InsertType == InsertType.Placement || obj.Ethereal) ? 1 + : path.CheckWalkable ? 2 + : path.StepDown ? 3 + : path.Collide ? 4 + : obj.State.HasFlag(ObjectInfoState.Contact) ? 5 + : 6; + Console.WriteLine(System.FormattableString.Invariant( + $"[fc-dispatch] cell=0x{path.CheckCellId:X8} PATH={_p} stepUp={path.StepUp} stepDown={path.StepDown} chkWalk={path.CheckWalkable} insert={path.InsertType} collide={path.Collide} contact={obj.State.HasFlag(ObjectInfoState.Contact)} ethereal={obj.Ethereal} c0=({sphere0.Center.X:F3},{sphere0.Center.Y:F3},{sphere0.Center.Z:F3}) hasS1={sphere1 is not null}")); + } + // Helper: transform a local-space vector to world space. // ACE: path.LocalSpacePos.LocalToGlobalVec(v) Vector3 L2W(Vector3 v) => Vector3.Transform(v, localToWorld); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 2a6913ac..52061923 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2161,6 +2161,23 @@ public sealed class Transition var sp = SpherePath; if (engine.DataCache is null) return TransitionState.OK; + // Retail check_other_cells (acclient_2013_pseudo_c.txt:272735) calls each + // other cell's find_collisions with `this`, so it reads the CURRENT + // this->sphere_path.global_sphere — i.e. the position AFTER the primary + // insert_into_cell ran. The primary collide can MOVE the sphere: a Path-5 + // full-hit dispatches step_sphere_up, and a successful step_up climbs the + // foot sphere onto a higher surface yet still returns OK. The caller + // captured `footCenter` BEFORE that primary collide, so using it here + // queries the other cells at the PRE-climb position. At the cellar lip + // that pre-climb center is sunk ~0.25 m below the cottage floor, so the + // foot sphere spuriously near-misses the very floor it just climbed onto → + // neg_step_up → a doomed second step_up against the floor normal (0,0,1) + // whose step_up_slide unwinds the climb → validate_transition reverts the + // whole step → 0% advance (the P2 cellar-lip wedge). Re-read the live + // foot-sphere center so check_other_cells sees the post-climb (resting, + // tangent) position, where the floor no longer overlaps. (2026-06-05) + footCenter = sp.GlobalSphere[0].Origin; + uint containingCellId = CellTransit.FindCellSet( engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet); LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius); diff --git a/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs index fc6423b3..c88c872f 100644 --- a/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs @@ -340,26 +340,123 @@ public class CellarLipWedgeTests } /// - /// FAITHFUL documents-the-bug (passes while the wedge exists; FAILS when the - /// fix lands → flip to assert the climb). Replays a captured FLOOR-contact- - /// plane wedge through the lip-cell engine; the player is STUCK (0% advance). + /// TEMP diagnostic (2026-06-05): trace ONE record by index with full probes, + /// to a per-index %TEMP%/lip-trace-{idx}.log. Used to compare a ramp record + /// (no sliding normal) against a floor record. STRIP after fix. + /// + [Theory] + [InlineData(6)] // STILL 0% post-footCenter-fix: flat floor, sliding normal (0,-1,0) + [InlineData(13)] // STILL 0% post-footCenter-fix: ramp, NO sliding normal, motion -X,+Y + [InlineData(0)] // STILL 0% post-footCenter-fix: ramp, sliding normal (0,-1,0) + [InlineData(21)] // STILL 0% post-footCenter-fix: ramp, NO slide, motion -X,-Y (away?) + public void Diagnostic_TraceRecordByIndex(int idx) + { + var rec = LoadWedgeRecords()[idx]; + var saved = Console.Out; + var sw = new StringWriter(); + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + PhysicsDiagnostics.ProbeStepWalkEnabled = true; + Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1"); + Console.SetOut(sw); + try + { + var (res, req, adv) = ReplayRecord(rec); + Console.SetOut(saved); + var bb = rec.BodyBefore!; + File.WriteAllText(Path.Combine(Path.GetTempPath(), $"lip-trace-{idx}.log"), + $"record #{idx} cur=({rec.Input.CurrentPos.X:F4},{rec.Input.CurrentPos.Y:F4},{rec.Input.CurrentPos.Z:F4}) " + + $"tgt=({rec.Input.TargetPos.X:F4},{rec.Input.TargetPos.Y:F4},{rec.Input.TargetPos.Z:F4}) " + + $"cp=({bb.ContactPlane.Normal.X:F2},{bb.ContactPlane.Normal.Y:F2},{bb.ContactPlane.Normal.Z:F2}) " + + $"slide=({bb.SlidingNormal.X:F2},{bb.SlidingNormal.Y:F2},{bb.SlidingNormal.Z:F2}) ts=0x{bb.TransientState:X2} " + + $"req={req:F3} adv={adv:F3} res=({res.X:F4},{res.Y:F4},{res.Z:F4})\n\n" + sw.ToString()); + Assert.True(true); + } + finally + { + Console.SetOut(saved); + Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null); + PhysicsDiagnostics.ProbeIndoorBspEnabled = false; + PhysicsDiagnostics.ProbeStepWalkEnabled = false; + } + } + + /// + /// FIX VALIDATION (2026-06-05) — the stale-footCenter fix in + /// RunCheckOtherCellsAndAdvance. Retail's check_other_cells + /// (acclient_2013_pseudo_c.txt:272735) re-collides the OTHER cells against the + /// LIVE sphere_path.global_sphere — i.e. AFTER the primary cell's + /// insert_into_cell may have moved the sphere via a successful + /// step_sphere_up. acdream captured the foot-sphere center BEFORE the + /// primary collide and reused that stale snapshot, so once the lip-riser + /// step_up climbed the foot onto the cottage floor, check_other_cells + /// still queried 0171 at the pre-climb (sunk, penetrating) position → the foot + /// spuriously near-missed the very floor it had climbed onto → a doomed second + /// step_up against the floor normal whose slide unwound the climb → + /// validate_transition reverted → 0% advance. /// /// - /// Root cause (traced via Diagnostic_ReplayFloorCpRecord_StepUpProbes): - /// the player is at the doorway EDGE of the cottage floor (0171, poly 0x0023, - /// Z=94). The step-up's step-down finds that floor and the 0.48 sphere - /// OVERLAPS it (0.085 m below), but acdream's walkable check REJECTS it - /// because the sphere center projects outside the floor poly's edge - /// (insideEdges=False, gap=−0.395) → no contact plane → step-up - /// fails → StepUpSlide=Collided. Retail accepts the floor at its edge and - /// crosses (0175 never blocks). Fix is in the walkable-edge acceptance - /// (WalkableHitsSphere / PolygonHitsSpherePrecise / CheckWalkable edge math) - /// — compare retail CPolygon::walkable_hits_sphere + check_walkable. DOOR - /// REGRESSION RISK: walkable changes are global; visual-gate + door tests. + /// Pre-fix: 0/29 captured wedge records advanced. Post-fix: the ramp-climb + /// family (≈20/29) advances onto the cottage floor (Z≈94). This asserts a + /// representative ramp record (#9, cp Z=0.78, no sliding normal) now climbs. /// /// [Fact] - public void DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge() + public void Fix_StaleFootCenter_RampRecordClimbsCottageFloor() + { + var rec = LoadWedgeRecords()[9]; + var (res, requested, advance) = ReplayRecord(rec); + Assert.True(advance > 0.25f * requested && res.Z >= CottageFloorZ - 0.05f, + $"Expected ramp record #9 to climb onto the cottage floor after the " + + $"stale-footCenter fix. advance={advance:F3} (req={requested:F3}), " + + $"res=({res.X:F3},{res.Y:F3},{res.Z:F3}); want advance>0.25·req and Z≥{CottageFloorZ - 0.05f:F2}."); + } + + /// + /// FIX REGRESSION GUARD (2026-06-05): the majority of captured wedge records + /// advance after the stale-footCenter fix. Pre-fix 0/29 → post-fix ≈20/29. + /// A drop here means check_other_cells is again querying other cells at + /// a stale pre-step_up position (the cellar-lip wedge regressed). + /// + [Fact] + public void Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance() + { + var recs = LoadWedgeRecords(); + int advanced = 0, total = 0; + foreach (var rec in recs) + { + if (rec.BodyBefore is null) continue; + total++; + var (res, req, adv) = ReplayRecord(rec); + if (adv > 0.25f * req) advanced++; + } + Assert.True(advanced >= 18, + $"Expected ≥18 of {total} captured wedge records to advance >0.25·req " + + $"after the stale-footCenter fix; got {advanced}."); + } + + /// + /// DOCUMENTS-THE-BUG (passes while a RESIDUAL wedge exists; flip when fixed). + /// Record #6 is a FLOOR-contact-plane record that ALSO carries a stale + /// (0,-1,0) sliding normal (the cottage south wall). The stale-footCenter + /// fix does NOT clear it: AdjustOffset's slide-crease projects the + /// into-cottage +Y motion onto the floor×wall crease (the world X axis) and + /// ZEROES it before the sphere moves, so only the −X residual survives → it + /// full-hits the slab's −X wall → a step_up that fails on the flat floor (no CP) + /// → Collided → revert → 0% advance. + /// + /// + /// This is the SEPARATE "(0,-1,0) sliding-normal +Y-kill" family (7/29 records). + /// It is slide-recovery territory — explicitly OUT OF SCOPE for this pass per + /// the kickoff ("do not re-investigate ... slide") — and is suspected to be a + /// buggy-trajectory artifact (the stale slide accumulated only because the + /// player was already oscillating; once the ramp-climb advances cleanly the + /// player should not enter the south-wall-slide-into-doorway state). The visual + /// gate decides whether it needs a follow-up. If the residual is fixed, flip + /// this to require advance > 0.25·requested. + /// + /// + [Fact] + public void DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY() { var recs = LoadWedgeRecords(); var rec = recs.First(r => r.BodyBefore is not null @@ -367,10 +464,10 @@ public class CellarLipWedgeTests var (res, requested, advance) = ReplayRecord(rec); var c = rec.Input.CurrentPos; var t = rec.Input.TargetPos; Assert.True(advance < 0.1f * requested, - $"DOCUMENTS-THE-BUG: expected the player STUCK at the cottage-floor edge. " + + $"DOCUMENTS-RESIDUAL: expected the player STUCK (sliding-normal +Y-kill). " + $"Instead it advanced: cur=({c.X:F3},{c.Y:F3},{c.Z:F3}) tgt=({t.X:F3},{t.Y:F3},{t.Z:F3}) " + $"res=({res.X:F3},{res.Y:F3},{res.Z:F3}) requested={requested:F3} advance={advance:F3}. " + - $"If the walkable-edge fix landed, FLIP this to require advance>0.25·requested."); + $"If the slide +Y-kill residual is fixed, FLIP this to require advance>0.25·requested."); } private static PhysicsEngine BuildEngineWithLipFixtures() From 9fdf6a5d01b972c3d2cb708b9a568089df716916 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 09:24:20 +0200 Subject: [PATCH 010/172] chore(p2): strip cellar-lip dispatch-trace probes after visual confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-footCenter fix (cc4590f) is visually confirmed: cellar ascent is smooth, inn door still blocks, generic step-up still climbs. The residual 9/29 (0,-1,0)-sliding-normal records did NOT manifest in live play — confirming they were buggy-trajectory artifacts. Remove the temporary investigation scaffolding added for this trace: - [fc-dispatch] probe in BSPQuery.FindCollisions - [step-sphere-down] probe in BSPQuery.StepSphereDown - CellarLipWedgeTests.Diagnostic_TraceRecordByIndex [Theory] Kept: the fix, the Fix_StaleFootCenter_* regression guards, and the DocumentsResidualWedge_* documents-the-bug test. Core suite 1317 pass / 4 fail (documented baseline) / 1 skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Physics/BSPQuery.cs | 28 ------------- .../Physics/CellarLipWedgeTests.cs | 41 ------------------- 2 files changed, 69 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index cafb0616..54007754 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1236,20 +1236,9 @@ public static class BSPQuery ResolvedPolygon? polyHit = null; ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it - // TEMP diagnostic (cellar-lip wedge dispatch trace, 2026-06-05): Path 3 - // reached — log the step-down probe inputs + the walkable-finder result so - // we can see whether the cottage floor is tested + accepted. STRIP after fix. - if (PhysicsDiagnostics.ProbeIndoorBspEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[step-sphere-down] ENTER cell=0x{path.CheckCellId:X8} stepDownAmt={path.StepDownAmt:F3} walkInterp={path.WalkInterp:F3} move=({movement.X:F3},{movement.Y:F3},{movement.Z:F3}) center=({checkPos.Center.X:F3},{checkPos.Center.Y:F3},{checkPos.Center.Z:F3}) r={checkPos.Radius:F3}")); - FindWalkableInternal(root, resolved, path, validPos, movement, up, ref polyHit, ref _polyId, ref changed); - if (PhysicsDiagnostics.ProbeIndoorBspEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[step-sphere-down] RESULT cell=0x{path.CheckCellId:X8} changed={changed} poly={(polyHit is null ? "n/a" : $"0x{polyHit.Id:X4} n=({polyHit.Plane.Normal.X:F3},{polyHit.Plane.Normal.Y:F3},{polyHit.Plane.Normal.Z:F3})")}")); - if (changed && polyHit is not null) { // ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale @@ -1718,23 +1707,6 @@ public static class BSPQuery returnState: -1); } - // TEMP diagnostic (cellar-lip wedge dispatch trace, 2026-06-05): which of - // the 6 paths does this cell take? The path is flag-driven (BSP-independent), - // so the synthetic-leaf test reproduces it faithfully. Deduce the path from - // the dispatch order so a single line names path + every gating flag. - // Gated on ACDREAM_PROBE_INDOOR_BSP. STRIP once the wedge fix lands. - if (PhysicsDiagnostics.ProbeIndoorBspEnabled) - { - int _p = (path.InsertType == InsertType.Placement || obj.Ethereal) ? 1 - : path.CheckWalkable ? 2 - : path.StepDown ? 3 - : path.Collide ? 4 - : obj.State.HasFlag(ObjectInfoState.Contact) ? 5 - : 6; - Console.WriteLine(System.FormattableString.Invariant( - $"[fc-dispatch] cell=0x{path.CheckCellId:X8} PATH={_p} stepUp={path.StepUp} stepDown={path.StepDown} chkWalk={path.CheckWalkable} insert={path.InsertType} collide={path.Collide} contact={obj.State.HasFlag(ObjectInfoState.Contact)} ethereal={obj.Ethereal} c0=({sphere0.Center.X:F3},{sphere0.Center.Y:F3},{sphere0.Center.Z:F3}) hasS1={sphere1 is not null}")); - } - // Helper: transform a local-space vector to world space. // ACE: path.LocalSpacePos.LocalToGlobalVec(v) Vector3 L2W(Vector3 v) => Vector3.Transform(v, localToWorld); diff --git a/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs index c88c872f..4f837936 100644 --- a/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs @@ -339,47 +339,6 @@ public class CellarLipWedgeTests } } - /// - /// TEMP diagnostic (2026-06-05): trace ONE record by index with full probes, - /// to a per-index %TEMP%/lip-trace-{idx}.log. Used to compare a ramp record - /// (no sliding normal) against a floor record. STRIP after fix. - /// - [Theory] - [InlineData(6)] // STILL 0% post-footCenter-fix: flat floor, sliding normal (0,-1,0) - [InlineData(13)] // STILL 0% post-footCenter-fix: ramp, NO sliding normal, motion -X,+Y - [InlineData(0)] // STILL 0% post-footCenter-fix: ramp, sliding normal (0,-1,0) - [InlineData(21)] // STILL 0% post-footCenter-fix: ramp, NO slide, motion -X,-Y (away?) - public void Diagnostic_TraceRecordByIndex(int idx) - { - var rec = LoadWedgeRecords()[idx]; - var saved = Console.Out; - var sw = new StringWriter(); - PhysicsDiagnostics.ProbeIndoorBspEnabled = true; - PhysicsDiagnostics.ProbeStepWalkEnabled = true; - Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1"); - Console.SetOut(sw); - try - { - var (res, req, adv) = ReplayRecord(rec); - Console.SetOut(saved); - var bb = rec.BodyBefore!; - File.WriteAllText(Path.Combine(Path.GetTempPath(), $"lip-trace-{idx}.log"), - $"record #{idx} cur=({rec.Input.CurrentPos.X:F4},{rec.Input.CurrentPos.Y:F4},{rec.Input.CurrentPos.Z:F4}) " + - $"tgt=({rec.Input.TargetPos.X:F4},{rec.Input.TargetPos.Y:F4},{rec.Input.TargetPos.Z:F4}) " + - $"cp=({bb.ContactPlane.Normal.X:F2},{bb.ContactPlane.Normal.Y:F2},{bb.ContactPlane.Normal.Z:F2}) " + - $"slide=({bb.SlidingNormal.X:F2},{bb.SlidingNormal.Y:F2},{bb.SlidingNormal.Z:F2}) ts=0x{bb.TransientState:X2} " + - $"req={req:F3} adv={adv:F3} res=({res.X:F4},{res.Y:F4},{res.Z:F4})\n\n" + sw.ToString()); - Assert.True(true); - } - finally - { - Console.SetOut(saved); - Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null); - PhysicsDiagnostics.ProbeIndoorBspEnabled = false; - PhysicsDiagnostics.ProbeStepWalkEnabled = false; - } - } - /// /// FIX VALIDATION (2026-06-05) — the stale-footCenter fix in /// RunCheckOtherCellsAndAdvance. Retail's check_other_cells From 41db027f34577b6b946d8fd82bf2882e7781d8b6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 09:24:54 +0200 Subject: [PATCH 011/172] docs(p2): record cellar-lip wedge visual-gate PASS (cellar smooth, door blocks, step-up climbs) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md b/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md index a5576c11..edde0964 100644 --- a/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md +++ b/docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md @@ -1,6 +1,15 @@ # P2 cellar-lip wedge — CANONICAL handoff (flat-floor contact-plane coin-flip) -## ✅ PRIMARY ROOT CAUSE FOUND + FIXED 2026-06-05 (START HERE — supersedes UPDATE 2 below) +## ✅ FIXED + VISUAL-GATE PASSED 2026-06-05 (START HERE — supersedes UPDATE 2 below) + +**VISUAL GATE PASSED (user, 2026-06-05): "Yes all works!"** — cellar ascent smooth (no last-step wedge), +inn door still BLOCKS, generic step-up still climbs. The residual 9/29 sliding-normal records did NOT +manifest in live play, confirming they were buggy-trajectory artifacts (not a live issue). Commits: +`cc4590f` (fix + validation tests + handoff/memory) and `9fdf6a5` (strip the dispatch-trace probes). +**The P2 cellar-lip wedge is DONE.** (Commits are local on the branch — not pushed.) The rest of this +banner is the root-cause writeup. + + **The pinned "find_walkable is NEVER called during the step-down" (UPDATE 2) was a PROBE ARTIFACT.** A clean `[fc-dispatch]`/`[step-sphere-down]` trace (TEMP probes, gated on `ACDREAM_PROBE_INDOOR_BSP`, From 2c7948a9f1e9b1b4c5a636bb8f40b3b0a2f62091 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 09:46:52 +0200 Subject: [PATCH 012/172] docs: handoff + kickoff for Render Residual A (camera collision verbatim port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session wrap: cellar-lip wedge fixed + visual-verified (cc4590f/9fdf6a5/41db027). Next task per the plan = Render Residual A: keep the chase camera eye inside the player's cell by porting retail SmartBox::update_viewer verbatim (fixes interior walls going grey/transparent from inside). - New canonical handoff with copy-paste fresh-session kickoff prompt, the retail update_viewer decode, the V1 current-state map, the gap to pin (faithful start-cell + AdjustPosition fallbacks + the no-wall-hit cause), and the evidence-first plan ([flap-sweep] capture → deterministic SweepEye test → port). - Key finding recorded: find_valid_position (pc:273890) just calls find_transitional_position — the sweep function is faithful, NOT the divergence. - CLAUDE.md banner updated to point at the new state + handoff. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 20 +- ...-05-camera-collision-residual-a-handoff.md | 180 ++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-06-05-camera-collision-residual-a-handoff.md diff --git a/CLAUDE.md b/CLAUDE.md index 590cf0f0..8ddc5834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -763,7 +763,25 @@ H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven. **Currently working toward: M1.5 — Indoor world feels right** (resumed from 2026-05-20 baseline after Phase O ship). -**2026-06-03 — P1 membership DONE + P2 active (read this first).** The verbatim spatial-pipeline +**2026-06-05 — P2 cellar-lip wedge FIXED; next = Render Residual A (camera collision) (READ THIS FIRST).** +The "stuck on the last cellar step" wedge is FIXED + visual-verified (user: "Yes all works!" — cellar +ascent smooth, inn door still BLOCKS, generic step-up climbs). Root cause: `Transition.CheckOtherCells` +collided the other cells against a STALE pre-step-up `footCenter`; retail's `check_other_cells` reads the +LIVE `sphere_path.global_sphere` (pc:272735). Fix = re-read `footCenter = sp.GlobalSphere[0].Origin` in +`RunCheckOtherCellsAndAdvance` (commits `cc4590f`/`9fdf6a5`/`41db027`; 0/29→20/29 captured wedge frames +climb; zero regression; Core 1317p/4f/1s). This DISPROVED the prior "find_walkable never called" framing +(it was a probe-reading artifact — `find_walkable` IS called; the `[fc-dispatch]` cell logged the carried +cell, not the iterated one). The remaining 9/29 are a separate `(0,-1,0)` sliding-normal +Y-kill that did +NOT manifest in live play (buggy-trajectory artifact; documented as `DocumentsResidualWedge_*`, deferred — +slide territory). **NEXT per the plan = Render Residual A: camera collision** — verbatim port of retail +`SmartBox::update_viewer` (pc:92761) to keep the 3rd-person chase eye INSIDE the player's cell (fixes +interior walls going grey/transparent while inside). User-confirmed approach (verbatim port, no hybrids) ++ order (A→C→B; C = outside-looking-in `DrawPortal`, B = particles). **CANONICAL PICKUP:** +[`docs/research/2026-06-05-camera-collision-residual-a-handoff.md`](docs/research/2026-06-05-camera-collision-residual-a-handoff.md) +(+ cellar-lip writeup in the top banner of +[`docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md`](docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md)). + +**2026-06-03 — P1 membership DONE + P2 active (history; cellar-lip now FIXED per the 2026-06-05 banner above).** The verbatim spatial-pipeline port (master plan [`docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md`](docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md)) is the active effort. **P1 (membership) = DONE** — proven to ALREADY match retail; the believed diff --git a/docs/research/2026-06-05-camera-collision-residual-a-handoff.md b/docs/research/2026-06-05-camera-collision-residual-a-handoff.md new file mode 100644 index 00000000..7f29932c --- /dev/null +++ b/docs/research/2026-06-05-camera-collision-residual-a-handoff.md @@ -0,0 +1,180 @@ +# Handoff — Render Residual A: camera collision (verbatim port of `SmartBox::update_viewer`) — 2026-06-05 + +## ▶ FRESH-SESSION KICKOFF PROMPT (copy-paste) + +``` +Continue acdream M1.5 render work: Render Residual A — CAMERA COLLISION (keep the 3rd-person camera +eye inside the player's cell so interior walls stop going grey/transparent from inside). This is a +VERBATIM port of retail SmartBox::update_viewer — no hybrids, no bandaids (master-plan mandate). +Branch claude/thirsty-goldberg-51bb9b (do NOT branch/worktree; do NOT push without asking; NEVER +git stash/gc). PowerShell on Windows; launch logs are UTF-16 (Select-String / rg --encoding utf-16le, +NOT GNU grep). Use superpowers:systematic-debugging; the user pre-approved the verbatim-port APPROACH +and the A→C→B order, so when you reach the design step use superpowers:brainstorming only to present +the concrete port design for sign-off before editing. + +READ FIRST (in order): +1. docs/research/2026-06-05-camera-collision-residual-a-handoff.md (THIS file — canonical). +2. docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md (§3 residuals A/B/C; the + blue-hole DON'T-redo: never re-add a CurrCell write inside ResolveWithTransition/ResolveCellId). +3. docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md (§C Camera: C1/C3). +4. memory/reference_render_pipeline_state.md + project_camera_visibility_coupling.md. + +STATE: M1.5 "indoor world feels right." The cellar-lip step-up wedge is FIXED + visual-verified +(committed cc4590f/9fdf6a5/41db027 — check_other_cells now reads the LIVE sphere position). Per the +plan the next task is Render Residual A: camera collision. User-confirmed problem mapping: Residual A += interior walls/seams go grey/transparent WHILE INSIDE (the chase eye drifts OUT of the player's +cell → near walls back-face/clip away); Residual C = outside-looking-in glass-box (separate, bigger +DrawPortal phase, do AFTER A); Residual B = particles (smallest, last). + +GOAL: port retail SmartBox::update_viewer (0x453ce0, pc:92761) faithfully so [flap-cam] eyeInRoot=y +while inside and interior walls stay opaque. Retail behavior: pivot at player head → (indoor) pick the +PIVOT's cell via CPhysicsObj::AdjustPosition → SWEEP the 0.3 viewer_sphere pivot→sought-eye via a +CTransition, stop at first wall → viewer=curr_pos, viewer_cell=curr_cell → fallback AdjustPosition at +sought-eye → fallback snap-to-player. + +KEY FINDINGS (do NOT re-derive): +- find_valid_position (pc:273890) is literally `return find_transitional_position(this)` (pc:273898). + So acdream's SweepEye→ResolveWithTransition→FindTransitionalPosition IS the faithful sweep. The + sweep FUNCTION is NOT the divergence — do not re-port it. +- The sweep + viewer_cell are ALREADY wired (V1): RetailChaseCamera.Update (damped eye → pivot → + CollisionProbe.SweepEye) + PhysicsCameraCollisionProbe.SweepEye (viewer sphere r=0.3, moverFlags + IsViewer|PathClipped|FreeRotate|PerfectClip, gated on CameraDiagnostics.CollideCamera). +- THE BUG (per handoff §3 + the [flap-sweep] probe comment): the sweep RUNS but finds NO wall + (pulledIn≈0, resolved=Y, bsp=ok) → the eye flies to full chase distance (eyeInRoot=n ~90%) in cells + like 0xA9B40174/0175. Root cause of the no-wall-hit is NOT yet pinned. +- GAPS per master-plan C1: (a) faithful START-CELL — retail uses AdjustPosition to find the PIVOT's + cell; acdream passes the player cellId straight in. (b) the two AdjustPosition FALLBACKS are missing. + (c) C3 find_visible_child_cell (pc:311397) is not ported (viewer cell uses the sweep curr_cell — + fine for now). Whether (a)/(b) actually cause the no-wall-hit is UNVERIFIED — pin it with evidence. + +THE JOB (evidence-first; the saga lesson = do NOT guess): +1. Live capture: launch with ACDREAM_PROBE_FLAP=1 (+ CameraDiagnostics.CollideCamera on), stand inside + the Holtburg cottage, rotate the chase camera into a back wall. Capture [flap-sweep] (cell/resolved/ + bsp/desiredBack/eyeBack/pulledIn/collNormValid) + [flap-cam] (root/eyeInRoot). Use the probe-comment + fork in PhysicsCameraCollisionProbe.cs to read WHY: pulledIn≈0 + bsp=ok ⇒ the sweep reaches no wall + geometry in the candidate set (clip/candidate-cell issue or wrong start cell); resolved=n/bsp=nobsp + ⇒ collision can't run there (cell/BSP not loaded). +2. Diagnose the no-wall-hit from the capture (likely: the sweep's candidate-cell set doesn't include + the wall's cell, OR the start cell is wrong because AdjustPosition isn't seating the pivot). Confirm + against retail update_viewer before changing anything. +3. Port verbatim: the faithful start-cell (AdjustPosition for the pivot's cell, indoor branch) + the + two AdjustPosition fallbacks, plus whatever the capture proves is the no-wall-hit cause. Consider a + DETERMINISTIC SweepEye test (cell fixture + seed pivot/eye, assert the sweep stops at the wall) — + the CellarLipWedgeTests pattern made the stairs fix iterable in <200ms; do the same here. +4. VALIDATE: eyeInRoot=y inside; build + Core(1317p/4f/1s)/App green. VISUAL GATE: stand inside the + cottage + rotate — interior walls stay solid (no grey/transparent, no NPCs/particles through walls); + inside-looking-out still correct (don't regress the fixed flap); generic outdoor chase unaffected. + +DO NOT: guess / speculative-edit (the saga's failure mode); re-add a CurrCell write inside +ResolveWithTransition/ResolveCellId (the blue-hole clobber — CurrCell is player-only via +UpdatePlayerCurrCell); conflate A (camera-eye containment, this task) with C (DrawPortal outside- +looking-in, next task); re-port find_valid_position/the sweep (it's faithful). + +TEST BASELINE: Core 1317 pass / 4 fail (documented: Apparatus_Grounded_50cmOffCenter, 2× +DoorBugTrajectoryReplay LiveCompare_*, BSPStepUpTests.D4) / 1 skip. App green. Branch HEAD 41db027. +``` + +--- + +## 1. Session summary (2026-06-05) + +**Shipped + visual-verified: the P2 cellar-lip step-up wedge.** Root cause = `Transition.CheckOtherCells` +collided the other cells against a STALE `footCenter` snapshotted before the primary collide; after a +step-up climbed the foot onto the cottage floor, the stale (pre-climb, penetrating) position spuriously +near-missed that floor → a doomed second step-up → revert → 0% advance. Fix: re-read +`footCenter = sp.GlobalSphere[0].Origin` in `RunCheckOtherCellsAndAdvance` (retail `check_other_cells` +reads the live `sphere_path.global_sphere`, pc:272735). 0/29 → 20/29 captured wedge frames climb; zero +regression. User visual-gate: **"Yes all works!"** (cellar smooth, door blocks, step-up climbs). +Commits `cc4590f` (fix + tests) / `9fdf6a5` (strip probes) / `41db027` (visual-gate note). Full writeup ++ the disproven prior framings: [`2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md`](2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md) +(top banner) + memory `project_p2_door_stepup_findings`. + +**Then: picked + scoped the next task (this handoff).** Per the plan the next step after the collision +fix is Render Residual A — camera collision. Aligned with the user on the problem statement (the two +symptoms → residuals A/C) and the approach (verbatim port of `SmartBox::update_viewer`, A→C→B order). +Did the read-only investigation below; NO camera code changed (next session implements after the +evidence-first diagnosis). + +## 2. The problem (user-confirmed) + +| Symptom (user words) | Residual | Cause | Fix | +|---|---|---|---| +| Inside a building, walls/seams flicker grey/transparent; can see through walls | **A** (this task) | 3rd-person chase eye drifts OUTSIDE the player's cell → near walls seen from their back-faces → culled | camera collision: sweep the eye, stop at the wall, keep it in the cell | +| Outside looking in through a doorway, building is a see-through glass box; ground over the floor | **C** (next) | outdoor→interior portal render (retail `DrawPortal`) not built | build that render phase | +| Particles bleed through floor | **B** (last) | scene particles not cell-clipped (#104) | cell-link the emitters | + +Order **A → C → B**: A is smaller + builds the shared "which cell is the viewpoint in" machinery that C +also needs (shrinks C). Mechanism for "transparent wall" = **back-face culling** (a wall is a one-sided +sheet facing into the room; from outside the room you see its culled back) + the renderer drawing from +the **viewer's cell** then flooding portals (so the viewer's cell must be right). + +## 3. Retail target — `SmartBox::update_viewer` (0x453ce0, pc:92761) + +Decoded this session (read the decomp directly for the verbatim port): +1. If `player->cell == 0` → `reenter_visibility`; still 0 → `set_viewer(player_pos, 1)`, `viewer_cell=null`, return. +2. Compute the desired eye (`viewer_sought_position`) from the pivot (head + `pivot_offset`). +3. **Start cell:** if player indoor (`objcell_id >= 0x100`), `CPhysicsObj::AdjustPosition(&var_90, &viewer_sphere, &cell_1, 0, 1)` to find the PIVOT's cell; success → `cell = cell_1`, else `cell = player->cell`. Outdoor → `cell = player->cell`. +4. **Sweep:** `makeTransition` → `init_object(player, 0x5c)` → `init_sphere(1, &viewer_sphere, 1.0)` (ONE sphere) → `init_path(cell_1, pivot, sought_eye)` → `find_valid_position`. + - success → `set_viewer(curr_pos, 0)`, `viewer_cell = sphere_path.curr_cell`, return. + - **fallback 1:** `AdjustPosition(sought_eye, &viewer_sphere, &var_170, 0, 1)` → `set_viewer(var_120, 0)`, `viewer_cell = var_170`, return. + - **fallback 2:** `set_viewer(player_pos, 1)`, `viewer_cell = null`. +- `0x5c` = `IsViewer | PathClipped | FreeRotate | PerfectClip` (PathClipped = hard-stop at first contact). +- **`find_valid_position` (pc:273890) = `return find_transitional_position(this)` (pc:273898)** — the + sweep is the ordinary transition; acdream's `ResolveWithTransition` is faithful to it. **The sweep + function is NOT the divergence.** + +## 4. acdream current state (V1, partial) + +- `RetailChaseCamera.Update` ([src/AcDream.App/Rendering/RetailChaseCamera.cs:102](../../src/AcDream.App/Rendering/RetailChaseCamera.cs)): + damps `_dampedEye`; `pivotWorld = playerPos + (0,0,1.5)`; if `CameraDiagnostics.CollideCamera && + CollisionProbe != null` → `swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId)`; + `publishedEye = swept.Eye`, `ViewerCellId = swept.ViewerCellId`. (Collides into a LOCAL, leaves + `_dampedEye` clean to avoid wall-press oscillation — keep that.) +- `PhysicsCameraCollisionProbe.SweepEye` ([src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:24](../../src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs)): + shifts pivot/eye down by the radius (InitPath sphere-center convention), `ResolveWithTransition` + (viewer sphere r=0.3, height 0, isOnGround=false, body=null, moverFlags + `IsViewer|PathClipped|FreeRotate|PerfectClip`, `movingEntityId=selfEntityId`), returns swept eye + + `r.CellId`. **Passes the player `cellId` straight in — does NOT do retail's AdjustPosition pivot-cell; + has NO AdjustPosition fallbacks.** +- The `[flap-sweep]` probe (in SweepEye, gated `RenderingDiagnostics.ProbeFlapEnabled` = `ACDREAM_PROBE_FLAP`) + + the builder's `[flap-cam]`/`[flap]`/`[shell]`/`[vis]` probes are the diagnosis apparatus — already + in the tree. + +## 5. The gap to pin (next session, evidence-first) + +The symptom is "sweep runs, finds no wall" (`pulledIn≈0`, `eyeInRoot=n ~90%`). Candidates, in order of +suspicion: +1. **Start cell** — acdream passes the player cell; retail seats the start cell at the PIVOT via + `AdjustPosition`. If the pivot/eye path's walls live in a cell that isn't the start cell and isn't + reached by the sweep's `check_other_cells` candidate set, the sweep misses them. (Most likely + + matches master-plan C1's "faithful start-cell" gap.) +2. **Candidate-cell tracking across the multi-step sweep** — the eye is ~2.6 m behind the player and the + sweep subdivides; if the carried cell doesn't advance into the wall's cell, the wall poly is never + in the per-cell BSP queried. (Related to the Stage-1 membership work; the player path now tracks the + carried cell correctly — verify the camera sweep does too.) +3. **AdjustPosition missing** — fallbacks aside, retail's start-cell AdjustPosition may be what seats + the sweep so it engages geometry; acdream has no AdjustPosition port at all (check + `CPhysicsObj::AdjustPosition`). + +Pin with the live `[flap-sweep]` capture FIRST, then port. A deterministic `SweepEye` test (cottage +cell fixture, seed pivot inside + eye behind the back wall, assert the swept eye stops at the wall and +`ViewerCellId` stays the room) would make this iterable like the cellar-lip fix. + +## 6. Apparatus + anchors + +- **Probes:** `ACDREAM_PROBE_FLAP=1` → `[flap-sweep]` (PhysicsCameraCollisionProbe) + `[flap-cam]`/ + `[flap]`/`[shell]`/`[vis]` (CellVisibility / the render builder). `CameraDiagnostics.CollideCamera` + toggles the spring-arm. +- **Decomp anchors:** `SmartBox::update_viewer` 0x453ce0 pc:92761 · `find_valid_position` pc:273890 → + `find_transitional_position` pc:273613 · `CPhysicsObj::AdjustPosition` (grep the decomp) · + `CEnvCell::find_visible_child_cell` 0x52dc50 pc:311397 (C3, viewer child cell — not yet ported, + optional for A). +- **DON'T-redo:** the blue-hole fix (`UpdatePlayerCurrCell` player-only render-root write) — never + re-add a `CurrCell` write in `ResolveWithTransition`/`ResolveCellId`. Don't conflate A with C. + +## 7. Brainstorming state (for the fresh session) + +Approach + order are USER-APPROVED (verbatim port of `update_viewer`; A→C→B). The brainstorming design +step was NOT completed — resume by doing the evidence-first diagnosis (§5), then present the concrete +port design (start-cell + fallbacks + the no-wall-hit fix) for sign-off before editing (HARD-GATE: +no code until the design is approved). From 0ffc3f5be92f2c94847025d006db7e08b5c8083b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 10:44:04 +0200 Subject: [PATCH 013/172] =?UTF-8?q?docs(A):=20spec=20=E2=80=94=20verbatim?= =?UTF-8?q?=20SmartBox::update=5Fviewer=20completion=20(Render=20Residual?= =?UTF-8?q?=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live ACDREAM_PROBE_FLAP capture (Holtburg cottage/cellar) proved the V1 camera spring-arm already contains the eye (eyeInRoot=Y 99.75%, viewerCell never 0, indoor collide 97.6% in 0174). The dominant inside-cottage bluish void is the render-sealing residual C (DrawPortal), NOT the camera. This spec scopes the FAITHFUL completion of Residual A: port the two missing update_viewer pieces verbatim — the indoor start-cell seated at the pivot via CPhysicsObj::AdjustPosition (pc:280009) → CEnvCell::find_visible_child_cell (pc:311397), plus the two AdjustPosition/snap-to-player fallbacks — and land FindVisibleChildCell (which residual C also needs). Faithful layering (mirrors retail SmartBox→CPhysicsObj): primitives in Core (PhysicsEngine.AdjustPosition + CellTransit.FindVisibleChildCell + ResolveResult.Ok), orchestration in App PhysicsCameraCollisionProbe.SweepEye. Deterministic crux test (start-cell resolution) in Core.Tests with the cottage fixtures; SweepEye glue in App.Tests. Visible payoff is narrow (the cellar-corner, point 3); the cottage-room void stays for residual C. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-05-residual-a-camera-collision-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md diff --git a/docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md b/docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md new file mode 100644 index 00000000..6bd8351f --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md @@ -0,0 +1,114 @@ +# Render Residual A — Camera collision: verbatim `SmartBox::update_viewer` completion + +**Date:** 2026-06-05 · **Phase:** M1.5 render residual A · **Branch:** `claude/thirsty-goldberg-51bb9b` + +## 1. The finding (why this is a faithfulness completion, not a visible-bug fix) + +A live `ACDREAM_PROBE_FLAP` capture this session (Holtburg cottage + cellar) proved the +V1 camera spring-arm **already works**: + +| Metric | Result | Meaning | +|---|---|---| +| `[flap-cam] eyeInRoot` | 186,349 `Y` / 470 `n` | eye inside the player's cell **99.75%** | +| `viewerCell == 0` (eye in the void) | **0** of ~318k frames | the sweep never lands the eye in invalid space | +| indoor collide rate, cell `0174` | **97.6%** | spring-arm engages cell-BSP walls hard | + +The dominant inside-cottage **bluish void** (seeing other buildings / particles / NPCs through +the walls) is the render-**sealing** residual **C** (`PView::DrawPortal`), NOT the camera — the +eye is already in a valid cell, yet the renderer draws the GL clear colour past unsealed geometry. +User-confirmed. + +This task therefore **completes Residual A as a faithful verbatim port** and lands +`FindVisibleChildCell`, which **C also needs**. Its one shot at a *visible* win is the +**cellar-corner** (user point 3): there the player's feet are in the cellar but the pivot/head +is up at cottage-floor level, so the pivot-seated start cell genuinely differs from the feet cell — +the only configuration where the faithful start-cell changes the sweep's outcome. + +## 2. Retail target (the oracle — port verbatim) + +- `SmartBox::update_viewer` `0x453ce0` pc:92761 — start-cell → sweep → fallbacks. +- `CPhysicsObj::AdjustPosition` `0x511d80` pc:280009 — indoor → `find_visible_child_cell`; outdoor → `LandDefs::adjust_to_outside`. +- `CEnvCell::find_visible_child_cell` `0x52dc50` pc:311397 — `this`/portals/stab_list `point_in_cell`. +- `CEnvCell::GetVisible` `0x52dc10` pc:311378 — cell-graph resolve. +- `find_valid_position` pc:273890 = `return find_transitional_position(this)` pc:273613 — **the sweep is already faithful; do NOT re-port it.** +- `init_object(player, 0x5c)` = `IsViewer | PathClipped | FreeRotate | PerfectClip`; `init_sphere(1, viewer_sphere, 1.0)` (ONE sphere, r=0.3 pc:93314). + +Decoded `update_viewer` (indoor branch): +``` +pivot = head frame · pivot_offset +if player indoor (objcell_id >= 0x100): + if AdjustPosition(pivot, viewer_sphere) -> cell_1: start = cell_1 # seat start at the PIVOT + else: start = player->cell # fallback to feet cell +else: start = player->cell +sweep viewer_sphere pivot -> sought_eye, startCell=start, flags=0x5c # PathClipped = hard stop +if find_valid_position: set_viewer(curr_pos); viewer_cell = curr_cell; return +if AdjustPosition(sought_eye, viewer_sphere) -> var_170: # FALLBACK 1 + set_viewer(sought_eye); viewer_cell = var_170; return +set_viewer(player->m_position); viewer_cell = null # FALLBACK 2: snap to player +``` + +## 3. Design — faithful layering (Core primitives ← App orchestration) + +Retail's `update_viewer` is a **`SmartBox` (camera) method** that calls *down* into physics +(`CPhysicsObj::AdjustPosition`, `CTransition`). acdream mirrors that split exactly: + +### Core (`AcDream.Core.Physics`) — the physics primitives +- **`CellTransit.FindVisibleChildCell(IDataCache, uint startCellId, Vector3 worldPoint, bool useStabList)`** — + sibling of the existing `FindCellList` (retail `find_cell_list`); both are cell-membership + resolvers. Port of `find_visible_child_cell`: + ``` + start = cg.GetVisible(startCellId); if start == null: return 0 + if PointInsideCellBsp(start, toLocal(start, worldPoint)): return start.Id # point_in_cell + ids = useStabList ? start.VisibleCellIds : start.Portals.Select(OtherCellId) + foreach id in ids: + c = cg.GetVisible(id) + if c != null && PointInsideCellBsp(c, toLocal(c, worldPoint)): return c.Id + return 0 + ``` + Each candidate transforms `worldPoint` through its OWN `InverseWorldTransform` before the + BSP test (matches `CellTransit.cs:520`). +- **`PhysicsEngine.AdjustPosition(uint seedCellId, Vector3 worldPoint) -> (uint cellId, bool found)`** — + port of `CPhysicsObj::AdjustPosition`, indoor branch: `FindVisibleChildCell(seed, point, useStabList:true)`. + Outdoor branch (`seedLow < 0x100`) reuses the existing terrain-grid resolution. + Retail's `seen_outside -> adjust_to_outside` sub-fallback is **deferred** (not on the cottage/cellar + path; adding it unverified would be guessing — see §6). +- **`ResolveResult.Ok` (new `bool`, default `true`)** — surfaces the `ok` already computed at + `PhysicsEngine.cs:718` (`FindTransitionalPosition`), the faithful map of `find_valid_position != 0`. + Default-true → existing callers unaffected. + +### App (`AcDream.App.Rendering`) — the camera orchestration +- **`PhysicsCameraCollisionProbe.SweepEye`** gains the verbatim `update_viewer` body: + 1. indoor (`cellId >= 0x100`) → `start = AdjustPosition(cellId, pivot)` else `cellId`; + 2. sweep `pivot → desiredEye` from `start` (existing `ResolveWithTransition`, viewer flags); + 3. `r.Ok` → return `(swept eye, r.CellId)`; + 4. `!r.Ok` → `AdjustPosition(cellId, desiredEye)` → return `(desiredEye, thatCell)` (fallback 1); + 5. else → return `(playerPos, 0)` (fallback 2, snap to player). + `SweepEye` needs the player world position for fallback 2 → add a `Vector3 playerPos` parameter + to `ICameraCollisionProbe.SweepEye` (passed by `RetailChaseCamera.Update`). + +## 4. Tests + +- **Core.Tests (`CellarLipWedgeTests` pattern, RED→GREEN):** load cottage fixtures `0171/0174/0175`. + Seed the captured corner frame — player `(153.55, 9.32, 93.11)` in `0174`, pivot `(153.55, 9.32, 94.61)`. + Assert `AdjustPosition(0174, pivot)` / `FindVisibleChildCell(0174, pivot, true)` resolves the pivot + to its actual (floor-level) cell, not the cellar. <200 ms, iterable. +- **App.Tests:** focused `SweepEye` orchestration test — start-cell seated, fallback-2 snaps to + `playerPos` when the sweep fails. Fixtures loaded by the `SolutionRoot()` path-walk. + +## 5. Validation / visual gate + +- Core baseline **1317 pass / 4 fail (documented) / 1 skip** maintained (+ the new tests); App green. +- **Visual gate:** stand in the cottage cellar, press into a corner, rotate — the **cellar-corner + void should improve** (point 3). Inside-looking-out must be **unregressed**. The cottage-room + bluish void is **NOT** in scope (Residual C). + +## 6. No-shortcuts rules (per master plan §4) + +1. Every ported behaviour cites its decomp anchor (address + `pc:line`) in a comment. +2. No suppression flags / grace periods / `if (problem) return` guards. The two fallbacks are + retail's own; fallback 2 (snap-to-player) is the faithful "never leave the eye invalid", not a band-aid. +3. The `seen_outside → adjust_to_outside` sub-fallback inside `AdjustPosition` is deferred, not + stubbed — documented as out-of-path; revisit if a capture shows the camera needs it. +4. Do NOT re-add a `CurrCell` write inside `ResolveWithTransition`/`ResolveCellId` (the blue-hole + clobber — `CurrCell` is player-only via `UpdatePlayerCurrCell`). +5. Do NOT conflate A (eye containment) with C (`DrawPortal` outside-looking-in). From 5177b54bbe07eb31377d527afea1e9ec8d0518dd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 10:56:16 +0200 Subject: [PATCH 014/172] feat(A): port find_visible_child_cell + AdjustPosition (Render Residual A primitives) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two Core physics primitives retail's SmartBox::update_viewer calls down into, ported verbatim (TDD, 7 new tests): - CellTransit.FindVisibleChildCell (CEnvCell::find_visible_child_cell, pc:311397): return the cell whose cell-BSP point_in_cell contains a world point — start cell first, then (stab-list mode) the start's VisibleCellIds or (portal mode) its direct portals. Sibling of FindCellList. Mirrors FindCellList's null-CellBSP skip (CellTransit.cs:518) so a cell lacking hydrated CellBSP doesn't spuriously claim every point via PointInsideCellBsp's null-node "inside" default. - PhysicsEngine.AdjustPosition (CPhysicsObj::AdjustPosition, pc:280009): resolve a point's cell from a seed. Indoor (>=0x100) → FindVisibleChildCell(stab-list); outdoor → landcell snap (same grid lookup as ResolveCellId). The seen_outside sub-fallback is deferred (off the cottage/cellar path; spec §6). Both are unwired into any production path — they land the machinery update_viewer's start-cell + fallback 1 need (and that residual C also needs). The App SweepEye orchestration that calls them lands next. Decomp-faithful per the live-capture finding: A's V1 sweep already contains the eye (eyeInRoot=Y 99.75%, never void); this completes A as a verbatim port. Spec: docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Physics/CellTransit.cs | 63 ++++++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 49 ++++++++ .../CellTransitFindVisibleChildCellTests.cs | 113 ++++++++++++++++++ .../PhysicsEngineAdjustPositionTests.cs | 111 +++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 915f571f..4f66b5b8 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -343,6 +343,69 @@ public static class CellTransit } } + /// + /// Verbatim port of CEnvCell::find_visible_child_cell + /// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP + /// point_in_cell contains , checking the + /// start cell first (:311402), then — when is + /// true (retail arg3 != 0, :311444) — the start's stab_list + /// (), else (arg3 == 0, :311411) + /// its direct portal neighbours. Returns 0 when no cell contains the point + /// (retail return 0 at :311469). + /// + /// + /// Sibling of (retail find_cell_list) — both + /// resolve membership from the cell graph via . + /// Used by CPhysicsObj::AdjustPosition (pc:280028, arg5 = 1 → + /// stab-list mode) to seat the camera sweep's start cell at the head-pivot. + /// + /// + /// + /// acdream adaptation (matches at line 518): a cell + /// with no hydrated cannot run + /// point_in_cell, so it is treated as NOT containing the point (skipped), + /// rather than letting 's null-node + /// "inside" default make it spuriously claim every point. + /// + /// + public static uint FindVisibleChildCell( + PhysicsDataCache cache, uint startCellId, Vector3 worldPoint, bool useStabList) + { + var start = cache.GetCellStruct(startCellId); + if (start is null) return 0u; + + // this->point_in_cell(point) → return this (:311402-311405) + if (PointInCell(start, worldPoint)) return startCellId; + + if (useStabList) + { + // arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465) + foreach (uint id in start.VisibleCellIds) + if (PointInCell(cache.GetCellStruct(id), worldPoint)) return id; + } + else + { + // arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434) + foreach (var portal in start.Portals) + if (PointInCell(cache.GetCellStruct(portal.OtherCellId), worldPoint)) return portal.OtherCellId; + } + + return 0u; + } + + /// + /// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point: + /// transform to the cell's local frame, then . + /// A cell with no hydrated returns false (see + /// 's adaptation note). + /// + private static bool PointInCell(CellPhysics? cell, Vector3 worldPoint) + { + if (cell?.CellBSP?.Root is null) return false; + var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform); + return BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, local); + } + /// /// Top-level cell-tracking driver, ported from retail's /// CObjCell::find_cell_list (sphere variant). diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index dba7fc04..68d5d4f6 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -397,6 +397,55 @@ public sealed class PhysicsEngine return fallbackCellId; } + /// + /// Verbatim port of CPhysicsObj::AdjustPosition + /// (acclient_2013_pseudo_c.txt:280009): resolve which cell actually + /// contains , given a seed cell. Indoor + /// (objcell_id ≥ 0x100, :280020) → + /// in stab-list mode (retail arg5 = 1, :280028); outdoor (:280050) → + /// snap to the landcell under the point (retail LandDefs::adjust_to_outside, + /// the same grid lookup uses). Returns + /// found = false with the seed id unchanged when no cell resolves + /// (retail return 0, :280065). + /// + /// + /// SmartBox::update_viewer calls this to seat the camera sweep's start + /// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1 + /// at the sought eye (:280078). Retail's indoor seen_outside → + /// adjust_to_outside sub-fallback (:280037-280046) is deferred — not on the + /// cottage/cellar camera path (see the design spec §6). + /// + /// + public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint) + { + if (seedCellId == 0u) return (seedCellId, false); + + if ((seedCellId & 0xFFFFu) >= 0x0100u) + { + // Indoor: find_visible_child_cell(this, point, arg3 = 1) (:280028). + if (DataCache is null) return (seedCellId, false); + uint child = CellTransit.FindVisibleChildCell(DataCache, seedCellId, worldPoint, useStabList: true); + return child != 0u ? (child, true) : (seedCellId, false); + } + + // Outdoor: LandDefs::adjust_to_outside — snap to the landcell under the + // point (same grid lookup as ResolveCellId, lines 363-371). No building + // re-entry here: AdjustPosition's outdoor branch is the bare landcell snap. + foreach (var kvp in _landblocks) + { + var lb = kvp.Value; + float localX = worldPoint.X - lb.WorldOffsetX; + float localY = worldPoint.Y - lb.WorldOffsetY; + if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) + { + uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); + return ((kvp.Key & 0xFFFF0000u) | lowCellId, true); + } + } + + return (seedCellId, false); + } + /// /// Resolve an entity's movement from by /// applying (XY only) and computing the correct Z diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs new file mode 100644 index 00000000..a5613fb6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Render Residual A — verbatim port of CEnvCell::find_visible_child_cell +/// (acclient_2013_pseudo_c.txt:311397): given a start cell, a world point, +/// and a mode, return the cell whose cell-BSP point_in_cell contains the +/// point — checking the start cell itself, then (stab-list mode) the start's +/// VisibleCellIds or (portal mode) its direct portal neighbours. +/// +/// +/// This is the sibling of (retail +/// find_cell_list); both resolve cell membership from the cell graph. The +/// camera's SmartBox::update_viewer start-cell uses the stab-list mode +/// (AdjustPosition at pc:280028 passes arg5=1) to seat the sweep at +/// the PIVOT's cell, which differs from the feet cell at a low connector (the +/// cellar lip), where the pivot is up at floor level in a different cell. +/// +/// +/// +/// Geometry is identity-transform (cell-local == world) so the synthetic CellBSP +/// splitting planes read directly: cell A is the half-space Y≤3, cell B (in A's +/// stab list) is the half-space Y≥7, and Y∈(3,7) belongs to neither. +/// +/// +public class CellTransitFindVisibleChildCellTests +{ + private const uint StartCellId = 0xA9B40174u; // low 0x0174 ≥ 0x0100 → indoor + private const uint SiblingCellId = 0xA9B40171u; // the "room above" in StartCell's stab list + + [Fact] + public void PointInsideStartCell_ReturnsStartCell() + { + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(StartCellId, MakeCell(InteriorYAtMost(3f), new uint[] { SiblingCellId })); + cache.RegisterCellStructForTest(SiblingCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // P at Y=1 is inside A (Y≤3) → the "this" branch returns the start cell. + uint result = CellTransit.FindVisibleChildCell(cache, StartCellId, new Vector3(0f, 1f, 0f), useStabList: true); + + Assert.Equal(StartCellId, result); + } + + [Fact] + public void PointInStabListSibling_ReturnsSibling() + { + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(StartCellId, MakeCell(InteriorYAtMost(3f), new uint[] { SiblingCellId })); + cache.RegisterCellStructForTest(SiblingCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // P at Y=8 is outside A (Y≤3) but inside B (Y≥7), and B is in A's stab list. + uint result = CellTransit.FindVisibleChildCell(cache, StartCellId, new Vector3(0f, 8f, 0f), useStabList: true); + + Assert.Equal(SiblingCellId, result); + } + + [Fact] + public void PointInNoCell_ReturnsZero() + { + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(StartCellId, MakeCell(InteriorYAtMost(3f), new uint[] { SiblingCellId })); + cache.RegisterCellStructForTest(SiblingCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // P at Y=5 is in the gap: outside A (Y≤3) and outside B (Y≥7). + uint result = CellTransit.FindVisibleChildCell(cache, StartCellId, new Vector3(0f, 5f, 0f), useStabList: true); + + Assert.Equal(0u, result); + } + + [Fact] + public void UnknownStartCell_ReturnsZero() + { + var cache = new PhysicsDataCache(); + uint result = CellTransit.FindVisibleChildCell(cache, 0xDEADBEEFu, new Vector3(0f, 1f, 0f), useStabList: true); + Assert.Equal(0u, result); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + /// CellBSP root for the half-space Y ≤ + /// (interior on the −Y side; point_in_cell true when Y ≤ boundary). + private static CellBSPNode InteriorYAtMost(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), boundary), // dist = boundary − Y ≥ 0 ⇔ Y ≤ boundary + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + /// CellBSP root for the half-space Y ≥ . + private static CellBSPNode InteriorYAtLeast(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, 1f, 0f), -boundary), // dist = Y − boundary ≥ 0 ⇔ Y ≥ boundary + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new() + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = cellBspRoot }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(visibleCellIds), + }; +} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs new file mode 100644 index 00000000..631ac435 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Render Residual A — verbatim port of CPhysicsObj::AdjustPosition +/// (acclient_2013_pseudo_c.txt:280009): resolve which cell actually +/// contains a world point, given a seed cell. Indoor (objcell_id ≥ 0x100) +/// delegates to (stab-list mode, +/// retail arg5 = 1); outdoor snaps to the landcell under the point +/// (retail LandDefs::adjust_to_outside). SmartBox::update_viewer +/// uses it to seat the camera sweep's start cell at the head-pivot, and again +/// as fallback 1 at the sought eye. +/// +public class PhysicsEngineAdjustPositionTests +{ + private const uint FeetCellId = 0xA9B40174u; // indoor connector (the cellar lip) + private const uint RoomCellId = 0xA9B40171u; // indoor room above, in the feet cell's stab list + private const uint LandblockId = 0xA9B40000u; + + [Fact] + public void Indoor_PivotInStabListSibling_ResolvesSiblingAndFound() + { + var engine = BuildIndoorEngine(); + + // Pivot at Y=8 is outside the feet cell (Y≤3) but inside the room cell (Y≥7). + var (cellId, found) = engine.AdjustPosition(FeetCellId, new Vector3(0f, 8f, 0f)); + + Assert.True(found); + Assert.Equal(RoomCellId, cellId); + } + + [Fact] + public void Indoor_PointInNoCell_NotFound() + { + var engine = BuildIndoorEngine(); + + // Y=5 is in the gap between the feet cell (Y≤3) and the room cell (Y≥7). + var (_, found) = engine.AdjustPosition(FeetCellId, new Vector3(0f, 5f, 0f)); + + Assert.False(found); + } + + [Fact] + public void Outdoor_PointInLandblock_SnapsToLandcell() + { + var engine = BuildIndoorEngine(); // also registers the landblock + + // Outdoor seed (low byte < 0x100). A point inside the landblock snaps to a + // prefixed landcell with found=true (retail LandDefs::adjust_to_outside). + var (cellId, found) = engine.AdjustPosition(0xA9B40001u, new Vector3(12f, 12f, 50f)); + + Assert.True(found); + Assert.Equal(LandblockId, cellId & 0xFFFF0000u); // correct landblock prefix + Assert.True((cellId & 0xFFFFu) < 0x0100u); // an outdoor landcell low byte + } + + // ── fixture ──────────────────────────────────────────────────────────── + + private static PhysicsEngine BuildIndoorEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorYAtMost(3f), new uint[] { RoomCellId })); + cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // A flat stub landblock so the outdoor branch has a grid to snap to. + var heights = new byte[81]; + var heightTable = new float[256]; + engine.AddLandblock( + landblockId: LandblockId, + terrain: new TerrainSurface(heights, heightTable), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + return engine; + } + + private static CellBSPNode InteriorYAtMost(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), boundary), + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellBSPNode InteriorYAtLeast(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, 1f, 0f), -boundary), + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new() + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = cellBspRoot }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(visibleCellIds), + }; +} From 9e70031bc620477b364d080efa1b4bcffd792a5b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 11:10:32 +0200 Subject: [PATCH 015/172] feat(A): wire SweepEye to the verbatim update_viewer (start-cell + fallbacks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Render Residual A's faithful port: PhysicsCameraCollisionProbe.SweepEye now mirrors SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) end-to-end: - Start cell (pc:92824-92844): indoor (>=0x100) seats the sweep at the head-PIVOT via PhysicsEngine.AdjustPosition (the cellar-lip case — feet in the low connector, head up at floor level); outdoor keeps the player cell. - Sweep pivot -> sought-eye from the seated start cell (unchanged 0x5c viewer flags). - Success (pc:92870): set_viewer(curr_pos), viewer_cell = curr_cell. - Fallback 1 (pc:92878): AdjustPosition(sought_eye). - Fallback 2 / no-cell (pc:92775, 92886): snap to player, viewer_cell = null. This also makes cellId==0 faithful (was returning the desired eye; retail snaps to player_pos) and adds the playerPos arg to ICameraCollisionProbe.SweepEye. Supporting: ResolveResult.Ok surfaces FindTransitionalPosition's return (retail find_valid_position != 0, pc:273898) so SweepEye knows when to fall back. TDD: 11 new tests (FindVisibleChildCell 4, AdjustPosition 3, ResolveResult.Ok 2, SweepEye orchestration 2). The seating test's RED proved the sweep does NOT auto- advance feet->room, so the pivot-seated start cell is genuinely decisive. Core 1326 pass / 4 documented-fail / 1 skip; App 179 pass / 0 fail. No regression. Per the live-capture finding, the visible payoff is the cellar-corner (point 3); the cottage-room bluish void stays for residual C. Spec: docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/ICameraCollisionProbe.cs | 10 +- .../Rendering/PhysicsCameraCollisionProbe.cs | 65 ++++++---- .../Rendering/RetailChaseCamera.cs | 2 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 3 +- src/AcDream.Core/Physics/ResolveResult.cs | 9 +- .../Rendering/CameraCollisionIndoorTests.cs | 3 +- .../CameraCollisionUpdateViewerTests.cs | 117 ++++++++++++++++++ .../PhysicsCameraCollisionProbeTests.cs | 20 +-- .../Rendering/RetailChaseCameraTests.cs | 4 +- .../Physics/ResolveResultOkTests.cs | 45 +++++++ 10 files changed, 234 insertions(+), 44 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs diff --git a/src/AcDream.App/Rendering/ICameraCollisionProbe.cs b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs index 0f05c9a9..2ad67136 100644 --- a/src/AcDream.App/Rendering/ICameraCollisionProbe.cs +++ b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs @@ -21,9 +21,11 @@ public interface ICameraCollisionProbe /// /// Roll a collision sphere from to /// ; return the position it reaches without - /// penetrating geometry AND the cell it ended in. Returns - /// + unchanged - /// when nothing blocks the path or when is 0. + /// penetrating geometry AND the cell it ended in. Mirrors retail + /// SmartBox::update_viewer: when is indoor the + /// sweep's start cell is seated at the pivot, and when there is no start cell or + /// the sweep fails the eye snaps to (retail + /// set_viewer(player_pos), viewer cell null). /// - CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId); + CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos); } diff --git a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs index 4dc98c83..4bb194b2 100644 --- a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs +++ b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs @@ -21,22 +21,35 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics; - public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) + public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos) { - // No starting cell → nothing to sweep against; keep the desired eye + cell. - if (cellId == 0) return new CameraSweepResult(desiredEye, cellId); + // update_viewer: player->cell == 0 → set_viewer(player_pos, 1), viewer_cell = null + // (acclient_2013_pseudo_c.txt:92775). No cell to sweep against → snap to the player. + if (cellId == 0) return new CameraSweepResult(playerPos, 0u); - // SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) - // (the player foot-capsule convention). Retail's viewer_sphere center is - // (0,0,0), so shift the path DOWN by the radius to make the SPHERE CENTER - // travel pivot→eye, then add it back to the swept stop position. + // === Start cell (pc:92824-92844) === + // Indoor (objcell_id >= 0x100): seat the sweep's start cell at the head-PIVOT via + // CPhysicsObj::AdjustPosition (pc:92832) — the head can sit in a different cell than + // the feet (the cellar lip: feet in the low connector, head up at floor level). On + // failure retail falls back to player->cell. Outdoor: cell = player->cell (no AdjustPosition). + uint startCell = cellId; + if ((cellId & 0xFFFFu) >= 0x0100u) + { + var (pivotCell, found) = _physics.AdjustPosition(cellId, pivot); + if (found) startCell = pivotCell; + } + + // === Sweep the viewer_sphere pivot → sought-eye from the start cell (pc:92860-92868) === + // SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) (the player + // foot-capsule convention). Retail's viewer_sphere center is (0,0,0), so shift the + // path DOWN by the radius to make the SPHERE CENTER travel pivot→eye, then add it back. Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius); Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius); var r = _physics.ResolveWithTransition( currentPos: begin, targetPos: end, - cellId: cellId, + cellId: startCell, sphereRadius: ViewerSphereRadius, sphereHeight: 0f, // single sphere (no head sphere) stepUpHeight: 0f, // no step-up for a camera @@ -58,32 +71,34 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius); - // Phase U.4c spike apparatus (THROWAWAY — strip with ACDREAM_PROBE_FLAP). - // The post-fix [flap-cam] capture shows the eye flying to full chase distance - // (eyeInRoot=n ~90%) in cells like 0xA9B40174/0175 — i.e. this sweep is not - // stopping it. This line answers WHY, the fork that picks the primary residual - // fix: pulledIn≈0 with resolved=Y bsp=ok ⇒ the sweep ran but found NOTHING in - // that cell (space genuinely open, or wall geometry the per-cell sweep can't - // reach → clip-robustness is primary); resolved=n / bsp=nobsp/noroot ⇒ collision - // can't even run there (cell/BSP not loaded → camera-collision reliability is - // primary); pulledIn large ⇒ collision IS engaging (eye leaving is then expected - // through an opening). Paired per-frame with the builder's [flap]/[flap-cam]. + // [flap-sweep] camera-collision probe (ACDREAM_PROBE_FLAP), paired with the + // builder's [flap]/[flap-cam]. start = the pivot-seated start cell (vs cell = the + // player feet cell); ok = the sweep found a valid position (find_valid_position != 0). if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) { - var cp = _physics.DataCache?.GetCellStruct(cellId); + var cp = _physics.DataCache?.GetCellStruct(startCell); string bsp = cp?.BSP is null ? "nobsp" : (cp.BSP.Root is null ? "noroot" : "ok"); float desiredBack = Vector3.Distance(pivot, desiredEye); float eyeBack = Vector3.Distance(pivot, eye); System.Console.WriteLine( - $"[flap-sweep] cell=0x{cellId:X8} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " + + $"[flap-sweep] cell=0x{cellId:X8} start=0x{startCell:X8} ok={r.Ok} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " + $"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " + - $"collNormValid={r.CollisionNormalValid}"); + $"viewerCell=0x{r.CellId:X8} collNormValid={r.CollisionNormalValid}"); } - // Phase W single-viewpoint V1 (2026-06-03): surface the swept cell (r.CellId = - // sp.CurCellId) as the viewer cell — retail viewer_cell = sphere_path.curr_cell - // (update_viewer pc:92871). Graph-tracked, no AABB/grace → the U.4c flap source is gone. - return new CameraSweepResult(eye, r.CellId); + // success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871). + // Graph-tracked, no AABB/grace. + if (r.Ok) return new CameraSweepResult(eye, r.CellId); + + // === Fallback 1 (pc:92878-92883): AdjustPosition at the sought eye === + // The sweep found no valid position; try to seat the eye at its own cell. + // (Seed with the player cell — acdream's camera doesn't track the sought-eye's + // cell separately; the eye is near the player so its stab-list is the right one.) + var (eyeCell, eyeFound) = _physics.AdjustPosition(cellId, desiredEye); + if (eyeFound) return new CameraSweepResult(desiredEye, eyeCell); + + // === Fallback 2 (pc:92886-92887): set_viewer(player_pos), viewer_cell = null === + return new CameraSweepResult(playerPos, 0u); } /// Eye/pivot point → InitPath path point (subtract the sphere-center offset). diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index d05b907a..614935be 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -166,7 +166,7 @@ public sealed class RetailChaseCamera : ICamera ViewerCellId = cellId; if (CameraDiagnostics.CollideCamera && CollisionProbe is not null) { - var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId); + var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition); publishedEye = swept.Eye; ViewerCellId = swept.ViewerCellId; } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 68d5d4f6..4cc5005a 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -965,7 +965,8 @@ public sealed class PhysicsEngine sp.CurCellId != 0 ? sp.CurCellId : partialCellId, partialOnGround, collisionNormalValid, - collisionNormal); + collisionNormal, + Ok: false); // Render Residual A — the sweep failed (find_valid_position == 0) } // A6.P3 #98 capture: emit one JSON Lines record per player call, diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs index 63d18452..c9c6f2f1 100644 --- a/src/AcDream.Core/Physics/ResolveResult.cs +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -27,4 +27,11 @@ public readonly record struct ResolveResult( bool CollisionNormalValid = false, /// Outward surface normal of the wall the sphere hit. Used /// by the velocity-reflection step. Pointing away from the wall. - Vector3 CollisionNormal = default); + Vector3 CollisionNormal = default, + /// Render Residual A — whether the underlying + /// FindTransitionalPosition found a valid position (retail + /// find_valid_position != 0, pc:273898). False when the sweep had no + /// start cell or was immediately stuck. The camera SweepEye reads this + /// to trigger SmartBox::update_viewer's fallbacks. Default true + /// so existing callers are unaffected. + bool Ok = true); diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs index d0fb3b66..02b8f6eb 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs @@ -140,7 +140,8 @@ public class CameraCollisionIndoorTests pivot: PivotWorld, desiredEye: DesiredEye, cellId: IndoorCellId, - selfEntityId: 0u).Eye; + selfEntityId: 0u, + playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye; // The eye should be stopped before the exterior wall at Y=4.0. // Expected stopped eye Y ≈ 4.0 - ViewerSphereRadius = 3.7. diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs new file mode 100644 index 00000000..a5dae8ce --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Render Residual A — the verbatim SmartBox::update_viewer +/// (acclient_2013_pseudo_c.txt:92761) orchestration in +/// : seat the sweep's start +/// cell at the head-PIVOT when indoor (via AdjustPosition → +/// find_visible_child_cell), and snap the eye to the player when there is +/// no start cell / the sweep fails. +/// +public class CameraCollisionUpdateViewerTests +{ + private const uint FeetCellId = 0xA9B40174u; // cellar (low ≥ 0x0100 → indoor), interior Z ≤ 94 + private const uint RoomCellId = 0xA9B40171u; // cottage floor above, interior Z ≥ 94, in feet cell's stab list + private const uint LandblockId = 0xA9B40000u; + + /// + /// The cellar-lip case (user point 3): the player's FEET are in the low cellar + /// cell, but the head-PIVOT is up at cottage-floor level in a different cell. + /// Retail seats the sweep at the pivot's cell (AdjustPosition, pc:92832), + /// so the viewer cell is the room above — NOT the feet cell. Without the start-cell + /// port the sweep stays in the feet cell. + /// + [Fact] + public void SweepEye_IndoorPivotInCellAboveFeet_SeatsStartAtPivotCell() + { + var engine = BuildTwoCellEngine(); + var probe = new PhysicsCameraCollisionProbe(engine); + + var feet = new Vector3(0f, 0f, 93f); // in the feet cell (Z ≤ 94) + var pivot = new Vector3(0f, 0f, 94.5f); // head, up in the room cell (Z ≥ 94) + var eye = new Vector3(0f, 3f, 95.5f); // behind + up, still in the room region, no wall + + var result = probe.SweepEye(pivot, eye, cellId: FeetCellId, selfEntityId: 0u, playerPos: feet); + + Assert.Equal(RoomCellId, result.ViewerCellId); + } + + /// + /// Retail update_viewer snaps the viewer to the player position when the + /// player has no cell (pc:92775) and as fallback 2 when the sweep fails + /// (pc:92886): set_viewer(player_pos); viewer_cell = null. + /// + [Fact] + public void SweepEye_NoStartCell_SnapsToPlayer() + { + var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine()); + + var player = new Vector3(7f, 8f, 9f); + var result = probe.SweepEye( + pivot: new Vector3(7f, 8f, 10.5f), desiredEye: new Vector3(7f, 13f, 11f), + cellId: 0u, selfEntityId: 0u, playerPos: player); + + Assert.Equal(player, result.Eye); + Assert.Equal(0u, result.ViewerCellId); + } + + // ── fixture ──────────────────────────────────────────────────────────── + + private static PhysicsEngine BuildTwoCellEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // Feet cell: interior Z ≤ 94, in its stab list the room cell above. No portals + // (so the collision sweep cannot transit to the room — the start cell is decisive). + cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorZAtMost(94f), new uint[] { RoomCellId })); + // Room cell: interior Z ≥ 94, no walls, no portals. + cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorZAtLeast(94f), Array.Empty())); + + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock( + landblockId: LandblockId, + terrain: new TerrainSurface(heights, heightTable), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + return engine; + } + + private static CellBSPNode InteriorZAtMost(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, 0f, -1f), boundary), // dist = boundary − Z ≥ 0 ⇔ Z ≤ boundary + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellBSPNode InteriorZAtLeast(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, 0f, 1f), -boundary), // dist = Z − boundary ≥ 0 ⇔ Z ≥ boundary + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new() + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = cellBspRoot }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(visibleCellIds), + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs index c756c94c..dec7fd44 100644 --- a/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs @@ -27,18 +27,20 @@ public class PhysicsCameraCollisionProbeTests Assert.Equal(p.Z, back.Z, 5); } - // cellId == 0 means "no starting cell" — the probe must short-circuit and - // return the desired eye without touching the engine. + // cellId == 0 means "no starting cell" — retail update_viewer snaps the viewer + // to the player position (set_viewer(player_pos), viewer_cell = null; pc:92775), + // so the probe must short-circuit to playerPos without touching the engine. [Fact] - public void SweepEye_NoStartingCell_ReturnsDesiredEyeUnchanged() + public void SweepEye_NoStartingCell_SnapsToPlayer() { - var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine()); - var pivot = new Vector3(0f, 0f, 1.5f); - var eye = new Vector3(-2f, 0f, 2.2f); + var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine()); + var pivot = new Vector3(0f, 0f, 1.5f); + var eye = new Vector3(-2f, 0f, 2.2f); + var player = new Vector3(0f, 0f, 0f); - var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0); + var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0, playerPos: player); - Assert.Equal(eye, result.Eye); - Assert.Equal(0u, result.ViewerCellId); // cellId==0 → returned unchanged + Assert.Equal(player, result.Eye); + Assert.Equal(0u, result.ViewerCellId); // cellId==0 → snap to player, null viewer cell } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index 989dcb2b..6e7f8c92 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -453,7 +453,7 @@ public class RetailChaseCameraTests public int Calls; public Vector3 ReturnEye; public uint ReturnCell; - public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) + public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos) { Calls++; return new CameraSweepResult(ReturnEye, ReturnCell); @@ -571,7 +571,7 @@ public class RetailChaseCameraTests { public int Calls; public Vector3 ClampEye; - public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) + public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos) { Calls++; return new CameraSweepResult(Calls == 1 ? ClampEye : desiredEye, cellId); diff --git a/tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs new file mode 100644 index 00000000..d8dc8db2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs @@ -0,0 +1,45 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Render Residual A — surfaces the +/// FindTransitionalPosition return (retail find_valid_position != 0, +/// pc:273898) so the camera SweepEye can trigger SmartBox::update_viewer's +/// fallbacks when the sweep fails. Default true keeps every existing caller +/// (which never reads it) unaffected. +/// +public class ResolveResultOkTests +{ + [Fact] + public void NoStartCell_ReportsNotOk() + { + var engine = new PhysicsEngine(); + + // cellId == 0 → FindTransitionalPosition returns false at its first guard + // (TransitionTypes.cs:665) → the sweep did not find a valid position. + var r = engine.ResolveWithTransition( + currentPos: Vector3.Zero, targetPos: new Vector3(1f, 0f, 0f), cellId: 0u, + sphereRadius: 0.3f, sphereHeight: 0f, stepUpHeight: 0f, stepDownHeight: 0f, + isOnGround: false); + + Assert.False(r.Ok); + } + + [Fact] + public void ZeroMovementValidCell_ReportsOk() + { + var engine = new PhysicsEngine(); + + // Zero offset with a non-zero start cell → FindTransitionalPosition's + // zero-step path returns true (TransitionTypes.cs:718-723), no geometry needed. + var r = engine.ResolveWithTransition( + currentPos: new Vector3(5f, 5f, 5f), targetPos: new Vector3(5f, 5f, 5f), cellId: 0xA9B40001u, + sphereRadius: 0.3f, sphereHeight: 0f, stepUpHeight: 0f, stepDownHeight: 0f, + isOnGround: false); + + Assert.True(r.Ok); + } +} From 02837ad5dccc0b696b8fa3d932b7ec2ffc5e76df Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 11:49:31 +0200 Subject: [PATCH 016/172] =?UTF-8?q?docs(A):=20wrap=20Render=20Residual=20A?= =?UTF-8?q?=20=E2=80=94=20handoff=20+=20roadmap=20for=20the=20core=20insid?= =?UTF-8?q?e=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Residual A (camera collision = verbatim SmartBox::update_viewer) is SHIPPED + user-kept (0ffc3f5/5177b54/9e70031). Wrap it and hand off to the render session: - New canonical handoff (docs/research/2026-06-05-render-residual-a-shipped-core- inside-render-handoff.md): what A shipped, what A EXPOSED (the render roots at the viewer cell — clipRoot=CameraCell, GameWindow.cs:7322 — and A made that cell accurate, so the PVS flood from the viewer cell doesn't reach the player's cell → cellar floor drops), the reframing (the user's "step C" = the CORE inside render / R1 completion, NOT R2 outside-looking-in), the evidence-first job, KEEP/DON'T, the kickoff prompt. - CLAUDE.md banner: A SHIPPED; next = core inside render (R1 completion). - Render redesign spec: 2026-06-05 sync note (A shipped; R1 is actually incomplete — the bleed + cellar-floor drop are the unfinished flood/seal; next is R1, not R2). The visible problems (bleed + the floor A exposed) are the same family: the inside path still draws the whole outdoor world instead of retail's "inside → DrawInside only". A faithful DrawInside seals them by construction (render spec 2026-06-02 §2). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 35 +-- ...al-a-shipped-core-inside-render-handoff.md | 216 ++++++++++++++++++ ...6-06-02-render-pipeline-redesign-design.md | 8 + 3 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md diff --git a/CLAUDE.md b/CLAUDE.md index 8ddc5834..cb58ee6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -763,23 +763,24 @@ H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven. **Currently working toward: M1.5 — Indoor world feels right** (resumed from 2026-05-20 baseline after Phase O ship). -**2026-06-05 — P2 cellar-lip wedge FIXED; next = Render Residual A (camera collision) (READ THIS FIRST).** -The "stuck on the last cellar step" wedge is FIXED + visual-verified (user: "Yes all works!" — cellar -ascent smooth, inn door still BLOCKS, generic step-up climbs). Root cause: `Transition.CheckOtherCells` -collided the other cells against a STALE pre-step-up `footCenter`; retail's `check_other_cells` reads the -LIVE `sphere_path.global_sphere` (pc:272735). Fix = re-read `footCenter = sp.GlobalSphere[0].Origin` in -`RunCheckOtherCellsAndAdvance` (commits `cc4590f`/`9fdf6a5`/`41db027`; 0/29→20/29 captured wedge frames -climb; zero regression; Core 1317p/4f/1s). This DISPROVED the prior "find_walkable never called" framing -(it was a probe-reading artifact — `find_walkable` IS called; the `[fc-dispatch]` cell logged the carried -cell, not the iterated one). The remaining 9/29 are a separate `(0,-1,0)` sliding-normal +Y-kill that did -NOT manifest in live play (buggy-trajectory artifact; documented as `DocumentsResidualWedge_*`, deferred — -slide territory). **NEXT per the plan = Render Residual A: camera collision** — verbatim port of retail -`SmartBox::update_viewer` (pc:92761) to keep the 3rd-person chase eye INSIDE the player's cell (fixes -interior walls going grey/transparent while inside). User-confirmed approach (verbatim port, no hybrids) -+ order (A→C→B; C = outside-looking-in `DrawPortal`, B = particles). **CANONICAL PICKUP:** -[`docs/research/2026-06-05-camera-collision-residual-a-handoff.md`](docs/research/2026-06-05-camera-collision-residual-a-handoff.md) -(+ cellar-lip writeup in the top banner of -[`docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md`](docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md)). +**2026-06-05 — Render Residual A (camera collision) SHIPPED + user-kept; next = the CORE INSIDE RENDER (R1 completion) (READ THIS FIRST).** +Residual A = a verbatim port of retail `SmartBox::update_viewer` (pc:92761): the indoor sweep's start +cell is seated at the head-PIVOT via `AdjustPosition` (pc:280009) → `find_visible_child_cell` (pc:311397), +plus the two fallbacks + cellId==0 snap-to-player. Commits `0ffc3f5` (spec) / `5177b54` (Core primitives) +/ `9e70031` (`ResolveResult.Ok` + `SweepEye` orchestration); TDD, 11 new tests, no regression (Core +1326p/4f/1s, App 179p). **Key finding (live capture):** A's V1 sweep ALREADY contained the eye +(`eyeInRoot=Y` 99.75%, `viewerCell` never 0) — so A is a faithfulness completion, not the fix for the +dominant bluish void. **What A EXPOSED (the bridge to the next phase):** the render roots at the VIEWER +cell (`clipRoot = visibility.CameraCell`, GameWindow.cs:7322, Phase W V1 "one viewpoint"); A made that +cell ACCURATE (the eye's real cell), so when the player is in the cellar but the eye is up in the room, +`clipRoot = room` and the PVS flood from the room does NOT reach the cellar → the cellar floor drops. +The user chose **"Keep it"** (the faithful viewpoint) over revert. **NEXT = the CORE inside render (R1 +completion)** — make `DrawInside` flood + seal correctly from the viewer cell so the cottage/cellar +interior is SEALED: no bluish void, no see-through-to-other-buildings BLEED, cellar floor draws. This +(NOT the handoff-era "C / outside-looking-in", which is R2, a later phase) is what fixes the visible +problems; the locked design already exists. **CANONICAL PICKUP:** +[`docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md`](docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md) +(+ the LOCKED render design [`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`](docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md)). **2026-06-03 — P1 membership DONE + P2 active (history; cellar-lip now FIXED per the 2026-06-05 banner above).** The verbatim spatial-pipeline port (master plan diff --git a/docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md b/docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md new file mode 100644 index 00000000..d2d18a52 --- /dev/null +++ b/docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md @@ -0,0 +1,216 @@ +# Handoff — Render Residual A SHIPPED; next = the CORE inside render (R1 completion) — 2026-06-05 + +> **Canonical pickup for the next (render) session. Read this FIRST.** Residual A (camera collision) +> is a faithful verbatim port of `SmartBox::update_viewer` — shipped, tested, user-kept. It made the +> render's viewpoint *accurate*, which **exposed** the real next problem with precision: the inside +> render does not flood/seal correctly from the (now-correct) viewer cell. The "step C" the user asked +> for is therefore **not** the handoff-era "C / outside-looking-in" — it is the **core inside render +> (R1 completion)**. Branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; launch logs are +> UTF-16 (`Select-String` / `rg --encoding utf-16-le`, NOT GNU grep). + +--- + +## 0. TL;DR + +- **SHIPPED (A):** verbatim `SmartBox::update_viewer` — indoor start-cell seated at the head-pivot + (`AdjustPosition` → `find_visible_child_cell`) + the two fallbacks + cellId==0 snap-to-player. + Commits `0ffc3f5` (spec) / `5177b54` (Core primitives) / `9e70031` (SweepEye orchestration). TDD, + 11 new tests, no regression. User-kept (chose "Keep it" over revert). +- **The live-capture finding that scoped A:** A's V1 sweep *already* contained the eye (`eyeInRoot=Y` + 99.75%, `viewerCell` never 0, indoor collide 97.6%). So A is a faithfulness completion, not a + visible-bug fix. The dominant inside-cottage **bluish void / see-through-to-other-buildings is NOT + the camera — it is the render seal.** +- **What A EXPOSED (the handoff's whole point):** the render roots at the **viewer cell** + (`clipRoot = visibility.CameraCell`, GameWindow.cs:7322; Phase W V1 "one viewpoint"). A made that + cell *accurate* (the eye's real, collided cell). So when you stand in the cellar but the eye is up + in the room, `clipRoot = the room`, and the PVS flood from the room **does not reach the cellar** → + **the cellar floor drops.** Before A, `viewerCell ≈ playerCell` (the sweep started from the feet + cell), which *accidentally masked* this. The user accepted this interim floor-drop to keep the + faithful viewpoint. +- **NEXT = the core inside render (R1 completion):** make `DrawInside` flood + seal correctly from the + viewer cell. This fixes BOTH the **bleed** (point 1) AND the **floor** A exposed — they are the same + family. The locked design already exists: + [`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`](../superpowers/specs/2026-06-02-render-pipeline-redesign-design.md). +- **Test baseline:** Core **1326 pass / 4 fail (documented) / 1 skip**; App **179 pass / 0 fail**. + The 4 Core fails are pre-existing (2× `DoorBugTrajectoryReplay` LiveCompare, `BSPStepUpTests.D4`, + `DoorCollisionApparatus`). + +--- + +## 1. What shipped — Residual A (verbatim `SmartBox::update_viewer`, pc:92761) + +| Commit | What | Layer | +|---|---|---| +| `0ffc3f5` | design spec (`docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md`) | docs | +| `5177b54` | `CellTransit.FindVisibleChildCell` (CEnvCell::find_visible_child_cell pc:311397) + `PhysicsEngine.AdjustPosition` (CPhysicsObj::AdjustPosition pc:280009) | Core | +| `9e70031` | `ResolveResult.Ok` (surfaces `find_valid_position != 0`) + `PhysicsCameraCollisionProbe.SweepEye` orchestration (start-cell seating + fallback 1 + fallback 2 + cellId==0 snap-to-player) | Core + App | + +The orchestration mirrors `update_viewer` end-to-end: indoor (`objcell_id >= 0x100`) seats the sweep's +start cell at the **head-pivot** via `AdjustPosition` (the cellar lip: feet in the low connector, head +up at floor level); sweep `pivot → sought-eye` from that start; on success `set_viewer(curr_pos)`, +`viewer_cell = curr_cell`; fallback 1 = `AdjustPosition(sought_eye)`; fallback 2 / no-cell = snap to +player, `viewer_cell = null`. `SweepEye` gained a `playerPos` arg (for the snap). + +**Why A's visible payoff was nil this session (don't be surprised):** the seating only differs from the +feet cell when the feet are in a *thin connector* cell while the head is in a taller neighbour (the lip +— a transient). Two live captures: in the cottage room every frame had `start == cell` (0 seated of +80,605); in the cellar the seating *did* fire (1,687 frames, `start != cell`). No fallback ever fired +(`ok=False`: 0). So A is faithful + correct; its job was to make the viewpoint accurate, which it did. + +## 2. The exposure (READ THIS — it is the bridge to the next phase) + +The render roots + projects from **one viewpoint = the viewer cell** (Phase W V1, GameWindow.cs:7322, +7330-7333: *"the render root (clipRoot = the viewer cell). ONE viewpoint"*). `PortalVisibilityBuilder.Build(clipRoot, viewerEye, …)` +floods the PVS from `clipRoot`. + +A changed `viewerCell` from `≈ playerCell` (V1, sweep from the feet cell) to **the eye's actual cell** +(seated at the pivot). Live proof, player in the cellar (`playerCell=0xA9B40174`): + +``` +[flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174 eye=(155,12,96.5) player=(153,9,93) ×1466 frames +``` + +`clipRoot = viewerCell = 0171` (the room, where the eye is) while the player is in `0174` (the cellar). +The PVS flood from the room **does not reach the cellar** → the cellar floor (the player's cell) is not +drawn → **missing floor**. This is exactly the spec's predicted symptom: §1.3 + §8 call the grey/missing +cellar floor a **sealing bug** (the closed cell mesh not covering pixels, OR the flood not reaching the +cell). **Cause unconfirmed — confirm it first (evidence-first, §4 below).** + +The **bleed** (point 1: walls bluish, other buildings/particles/NPCs visible through them from inside) +is the same family: the inside path still draws the **whole outdoor world** then layers cell shells on +top, instead of retail's "inside → `DrawInside` only" (spec §2). A faithful `DrawInside` makes the bleed +impossible *by construction*. + +## 3. The reframing — "C" is the CORE inside render, not outside-looking-in + +The handoff-era residual letters (A camera / B particles / **C outside-looking-in**) map onto the locked +render spec's phases as: A camera (DONE), B → **R1b** (#104 particles), **C → R2** (`DrawPortal`, +street→interior). **R2 is a LATER phase.** The visible problems the user is hitting — the bleed AND the +floor A exposed — are **R1 (the core per-cell `DrawInside` flood+seal)**, which shipped only *partially* +(2026-06-03: the flap fix + basic shells + inside-looking-out, but NOT the "inside → DrawInside only" +inversion, NOT the general-case flood from `viewerCell != playerCell`). **The next session finishes R1.** + +## 4. The job (next session) — evidence-first, then verbatim PView + +1. **Read the locked design + its 4 research docs (in the spec's "Read first" list):** + `docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md` → + `docs/research/2026-06-02-render-pipeline-redesign-handoff.md` (root cause, the three-gate failure) → + `…-retail-render-pipeline-full-reference.md` (the PView + `DrawCells` seal) → + `…-acdream-render-pipeline-inventory-and-failures.md` (the `WbDrawDispatcher.cs:1756` bypass, the + parallel BFS, the terrain Skip model) → `…-render-reference-crosscheck.md` (why WB two-pipe is wrong). +2. **Confirm the floor cause FIRST (do NOT guess — spec §8):** launch with `ACDREAM_PROBE_FLAP=1` + `ACDREAM_PROBE_VIS=1` `ACDREAM_PROBE_SHELL=1`; stand in the cellar, get the eye up in the room + (`viewerCell=room`, `playerCell=cellar`). Read `[vis]` (does `OrderedVisibleCells` include the cellar + when rooted at the room?) + `[shell]` (does the cellar shell draw?) + dump the cellar EnvCell mesh + (is the floor polygon present + front-facing?). This decides: **PVS-flood-not-reaching** vs + **cell-mesh-not-sealing**. Get a screenshot EARLY (memory `render-one-gate`). +3. **Port the PView seal/flood verbatim (spec §2 + §4):** the binary top-level decision (inside → + `DrawInside` only — removes the global outdoor pass → kills the bleed by construction) + the per-cell + `DrawInside` loop (landscape-through-door → conditional Z-only clear → per-cell shells → per-cell + objects → per-cell particles). Retail anchors: `RenderNormalMode` 0x453aa0, `PView::DrawInside` + 0x5a5860, `ConstructView` 0x5a57b0, `DrawCells` 0x5a4840 (spec §11 has the full index). +4. **VALIDATE — visual gate (spec R1 gate):** Holtburg cottage + cellar — sealed interior (opaque walls, + **solid floor**, ceiling), sky/terrain through the door only, **no bluish void, no bleed** (no other + buildings/particles/NPCs through walls), no terrain under the floor. Build + Core(1326p/4f/1s)/App(179p) green. + +## 5. KEEP / DON'T-REDO + +**KEEP (do not reopen):** +- **Residual A** (the 3 commits). The viewer cell is now accurate — that is the *input* the render needs. + Do NOT revert it to mask the floor (the user explicitly chose to keep the faithful viewpoint). +- The Phase W V1 "one viewpoint" (clipRoot = viewer cell, project from the eye) — GameWindow.cs:7322-7338. +- The Stage-1 membership port + the blue-hole fix (`UpdatePlayerCurrCell`, player-only render-root). +- `PortalVisibilityBuilder` / `ClipFrame` / `EnvCellRenderer` / `TerrainModernRenderer` / the WB mesh + pipeline (spec §3 KEEP). + +**DON'T:** +- Don't re-add a `CurrCell` write inside `ResolveWithTransition` / `ResolveCellId` (the blue-hole clobber). +- Don't reintroduce the WB two-pipe stencil / `isInside` gate / AABB grace-frame for the root (spec §8). +- Don't relax the faithful terrain `Skip` to "fix" the grey floor (spec §1.3 — it's a sealing bug, not a + terrain-clip bug). +- Don't jump to R2 (outside-looking-in / `DrawPortal`) — R1 (the inside seal+flood) is first. + +## 6. KEY FILES + ANCHORS + +``` +RENDER (the next phase) + src/AcDream.App/Rendering/GameWindow.cs (OnRender ~7300-7610) ← clipRoot=viewerCell (7322); binary decision lives here + src/AcDream.App/Rendering/PortalVisibilityBuilder.cs ← the PVS BFS (KEEP; the flood to harden/port) + src/AcDream.App/Rendering/InteriorRenderer.cs ← per-cell DrawInside loop (partial) + src/AcDream.App/Rendering/EnvCellRenderer.cs ← per-cell shell mesh (Render(pass,{cellId})) + src/AcDream.App/Rendering/WbDrawDispatcher.cs (~1756) ← the ParentCellId==null bypass to delete + src/AcDream.App/Rendering/ClipFrameAssembler.cs / CellVisibility.cs + docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md ← LOCKED design (§2 model, §4 seal, §7 phases, §11 anchors) + +CAMERA (A — shipped, the input) + src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs ← SweepEye = verbatim update_viewer + src/AcDream.Core/Physics/CellTransit.cs FindVisibleChildCell ← pc:311397 + src/AcDream.Core/Physics/PhysicsEngine.cs AdjustPosition ← pc:280009 + +PROBES + ACDREAM_PROBE_FLAP=1 [flap-cam] root/viewerCell/playerCell/eyeInRoot + [flap] PVS BFS + [flap-sweep] camera (start vs cell, ok) + ACDREAM_PROBE_VIS=1 [vis] OrderedVisibleCells + OutsideView + ACDREAM_PROBE_SHELL=1 [shell] per-cell shell draw + ACDREAM_PROBE_CELL=1 [cell-transit] player CellId changes +``` + +## 7. RUNNING THE CLIENT (PowerShell; `+Acdream` spawns in the Holtburg cottage) + +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" +$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" +$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" +$env:ACDREAM_PROBE_FLAP="1"; $env:ACDREAM_PROBE_VIS="1"; $env:ACDREAM_PROBE_SHELL="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *>&1 | Tee-Object -FilePath launch.log +``` +Build green BEFORE launching. Logs are UTF-16. Close gracefully (✕ / Alt+F4) so ACE clears the session in ~3-5s. + +## 8. KICKOFF PROMPT (copy-paste for the next session) + +``` +Continue acdream M1.5 render work: the CORE INSIDE RENDER (R1 completion) — make DrawInside flood + seal +correctly from the viewer cell so the cottage/cellar interior is SEALED: no bluish void, no see-through-to- +other-buildings BLEED, and the CELLAR FLOOR draws. This is what the user means by "step C" — it is NOT the +handoff-era "C / outside-looking-in" (that is R2, a later phase). Branch claude/thirsty-goldberg-51bb9b +(do NOT branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows; launch +logs are UTF-16 (Select-String / rg --encoding utf-16-le, NOT GNU grep). Use superpowers:systematic- +debugging; the render redesign DESIGN is already LOCKED (read the spec), so this is execution + an +evidence-first floor-cause confirmation, not a re-brainstorm. + +READ FIRST (in order): +1. docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md (THIS handoff — what + A shipped, what A EXPOSED §2, the reframing §3, the job §4, KEEP/DON'T §5, files §6). +2. docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md (the LOCKED design — §2 the one + model, §4 the seal mechanics, §7 phases/gates, §11 decomp anchors) + its 4 "Read first" research docs. +3. memory: reference_render_pipeline_state.md, feedback_render_one_gate.md, + feedback_render_downstream_of_membership.md, feedback_verify_render_seal_before_layering.md. + +STATE: Residual A (camera collision) SHIPPED + user-kept (commits 0ffc3f5/5177b54/9e70031) — verbatim +SmartBox::update_viewer; the viewer cell is now ACCURATE. That accuracy EXPOSED the next problem: the +render roots at the viewer cell (clipRoot=visibility.CameraCell, GameWindow.cs:7322), and the PVS flood +from the viewer cell does NOT reach the player's cell when they differ (eye in the room, player in the +cellar → clipRoot=room → cellar floor not drawn). Same family as the bleed (the inside path still draws +the whole outdoor world instead of "inside → DrawInside only"). + +THE JOB (evidence-first, then verbatim PView): +1. Confirm the floor cause FIRST (spec §8 flags it unconfirmed): launch with ACDREAM_PROBE_FLAP/_VIS/_SHELL, + stand in the cellar with the eye up in the room (viewerCell=room, playerCell=cellar), read [vis] + (is the cellar in OrderedVisibleCells when rooted at the room?) + [shell] + dump the cellar EnvCell + mesh (floor poly present + front-facing?). Decide PVS-flood-not-reaching vs cell-mesh-not-sealing. + Screenshot EARLY. +2. Port the PView seal/flood verbatim (spec §2 + §4): the binary top-level decision (inside → DrawInside + ONLY — removes the global outdoor pass → kills the bleed by construction) + the per-cell DrawInside loop + (landscape-through-door → conditional Z-only clear → per-cell shells → per-cell objects → per-cell + particles). Anchors: RenderNormalMode 0x453aa0, PView::DrawInside 0x5a5860, ConstructView 0x5a57b0, + DrawCells 0x5a4840. +3. VALIDATE — visual gate: Holtburg cottage + cellar sealed (opaque walls, SOLID FLOOR, ceiling), sky/ + terrain through the door only, NO bluish void, NO bleed. Build + Core(1326p/4f/1s)/App(179p) green. + +DON'T: revert A to mask the floor (user chose to keep the faithful viewpoint); re-add a CurrCell write in +ResolveWithTransition/ResolveCellId (blue-hole); reintroduce the WB two-pipe / isInside gate / AABB grace +(spec §8); relax the faithful terrain Skip (spec §1.3 — it's a sealing bug); jump to R2 (outside-looking-in) +before R1 (the inside seal+flood) is done. + +TEST BASELINE: Core 1326 pass / 4 fail (documented: 2× DoorBugTrajectoryReplay LiveCompare, BSPStepUpTests.D4, +DoorCollisionApparatus) / 1 skip. App 179 pass / 0 fail. Branch HEAD 9e70031 (+ this handoff commit). +``` diff --git a/docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md b/docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md index 673918c6..dd3b9d1b 100644 --- a/docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md +++ b/docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md @@ -245,6 +245,14 @@ the closest-first order (early-Z) are the mitigations. Measure at the R1 gate; d > A = camera-collision (walls grey while inside; eye outside the cell) → fold into R4 or a focused phase; > B = particles through the floor → **R1b** (#104); C = transparent walls from the street → **R2** below. > Canonical: [`docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md`](../../research/2026-06-03-membership-and-bluehole-shipped-handoff.md). +> +> **UPDATE 2026-06-05:** Residual **A (camera collision) SHIPPED** as a verbatim `update_viewer` port +> (commits `0ffc3f5`/`5177b54`/`9e70031`). It made the viewer cell (which this pipeline roots on, +> `clipRoot = CameraCell`) **accurate**, which exposed that **R1 is actually INCOMPLETE**: the +> "inside → `DrawInside` only" inversion + the general-case flood were not finished, so the **bleed** +> (other buildings through walls) + the **cellar-floor drop** (PVS flood from the viewer cell doesn't +> reach the player's cell) remain. **The next phase is R1 completion (this §2/§4 work), NOT R2.** +> Canonical: [`docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md`](../../research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md). **Retail anchors:** `RenderNormalMode @ 0x453aa0` (binary decision), `PView::DrawInside @ 0x5a5860`, `ConstructView @ 0x5a57b0`, `DrawCells @ 0x5a4840` (the seal + the three per-cell loops), fact 8 From 5f596f2d25fafbc4815b530fa9c776bb1d89a887 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 15:27:24 +0200 Subject: [PATCH 017/172] fix(render): clip portal projection against frustum side planes (clip-space) ProjectToNdc clipped only the eye half-space (w>MinW, a 2026-06-03 workaround) and left the 4 frustum side planes to the 2D ScreenPolygonClip. When the eye is within a portal's near plane, small-w verts explode under the perspective divide (probe saw NDC (10.2,-67.4)); the 2D clip then collapses to empty -> OutsideView empty -> terrain Skip -> the bluish doorway void. Clip the eye plane + 4 side planes (homogeneous Sutherland-Hodgman) before the divide so NDC is bounded to the screen by construction, matching retail GetClip -> polyClipFinish (clip in clip-space before the divide; pc:432344). Partial: NOT the full flicker fix. The dominant cause (camera boom drift + viewer-cell flip at boundaries + missing w=0 near-plane clip) is identified and deferred to the next session per the handoff. 2 RED->GREEN tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/PortalProjection.cs | 44 ++++++++++----- .../Rendering/PortalProjectionTests.cs | 53 +++++++++++++++++++ 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index 902570af..53f1c0d1 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -1,9 +1,11 @@ // PortalProjection.cs // -// Phase A8.F: project a cell-local portal polygon to NDC screen space, clipping against the -// IN-FRONT-OF-EYE half-space (keep where w > MinW) so a portal straddling the camera does not -// invert under the perspective divide, and the divide stays bounded away from the w=0 eye -// singularity. +// Phase A8.F: project a cell-local portal polygon to NDC screen space. Homogeneous frustum clip +// in CLIP SPACE (before the perspective divide): first the IN-FRONT-OF-EYE half-space (keep where +// w > MinW) so a portal straddling the camera does not invert under the divide and the divide +// stays bounded away from the w=0 eye singularity, then the 4 SIDE planes (x,y within ±w) so every +// surviving vertex lands on the screen [-1,1] by construction. The side-plane clip is the R1 +// void-flap fix (2026-06-05) — see ProjectToNdc. // // The clip is NEAR-INDEPENDENT on purpose. We only use the projected x/y for the visibility clip // REGION, so a vertex in front of the eye is meaningful even if it is closer than the projection's @@ -38,10 +40,24 @@ public static class PortalProjection foreach (var lp in localPoly) clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); - // Clip against the in-front-of-eye half-space (keep where w > MinW). Near-independent: - // see the file header — clipping at the projection's near plane culls portals the camera - // is standing in (the doorway "void"). - clip = ClipBehindEye(clip); + // Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the + // in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is + // standing in still projects (see header); then the 4 SIDE planes (x,y within ±w). The + // side clip is the R1 void-flap fix (2026-06-05): without it, a portal WITHIN the near + // plane projected small-w verts to wildly off-screen NDC (the probe saw (10.2,-67.4)), + // which corrupted the downstream 2D ScreenPolygonClip into an EMPTY region -> OutsideView + // empty -> terrain Skip -> the bluish doorway "void". Clipping the side planes here bounds + // every surviving vertex to the screen [-1,1] by construction, so a screen-covering doorway + // clips to the screen (non-empty) instead of collapsing. The eye plane is clipped FIRST so + // all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined. + // Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip + // (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5). + clip = ClipPlane(clip, v => v.W - MinW); // in front of eye (near-independent) + if (clip.Count < 3) return System.Array.Empty(); + clip = ClipPlane(clip, v => v.W + v.X); // left: x/w >= -1 <=> w + x >= 0 + clip = ClipPlane(clip, v => v.W - v.X); // right: x/w <= 1 <=> w - x >= 0 + clip = ClipPlane(clip, v => v.W + v.Y); // bottom: y/w >= -1 <=> w + y >= 0 + clip = ClipPlane(clip, v => v.W - v.Y); // top: y/w <= 1 <=> w - y >= 0 if (clip.Count < 3) return System.Array.Empty(); // Perspective divide → NDC xy. @@ -60,16 +76,20 @@ public static class PortalProjection // standing in still projects and the cell behind it stays visible. See the file header. private const float MinW = 0.05f; - // Sutherland-Hodgman against the in-front-of-eye half-space: keep where w > MinW. - private static List ClipBehindEye(List poly) + // Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE. + // `dist` is the signed plane functional (>= 0 keeps the vertex); crossings are interpolated in + // homogeneous coords (perspective-correct). Callers apply the eye plane first so every survivor + // has w > 0, making the side-plane functionals (w ± x, w ± y) well defined. + private static List ClipPlane(List poly, System.Func dist) { + if (poly.Count == 0) return poly; var result = new List(poly.Count + 1); for (int i = 0; i < poly.Count; i++) { Vector4 cur = poly[i]; Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; - float dCur = cur.W - MinW; - float dPrev = prev.W - MinW; + float dCur = dist(cur); + float dPrev = dist(prev); bool curIn = dCur >= 0f; bool prevIn = dPrev >= 0f; diff --git a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs index 18f42409..c5e1e2bb 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs @@ -86,6 +86,59 @@ public class PortalProjectionTests } } + [Fact] + public void Project_QuadStraddlingCamera_NdcStaysWithinScreen() + { + // R1 void-flap fix (2026-06-05): the eye-plane-only clip (w>MinW) let small-w verts + // explode under the perspective divide (~±37 NDC). Those off-screen NDC then corrupted + // the downstream 2D ScreenPolygonClip, which at glancing/close angles collapsed to EMPTY + // -> OutsideView empty -> terrain Skip -> the bluish "void" at the cottage doorway. + // Clipping the 4 frustum SIDE planes in clip space (homogeneous, before the divide) + // bounds every projected vertex to the screen [-1,1] by construction. RED before the fix. + var poly = new[] + { + new Vector3(-1, -1, 2), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, 2), + }; + var r = PortalProjection.ProjectToNdc(poly, Matrix4x4.Identity, ViewProj()); + Assert.True(r.Length >= 3); + foreach (var v in r) + { + Assert.InRange(v.X, -1.001f, 1.001f); // bounded to the screen — no off-screen explosion + Assert.InRange(v.Y, -1.001f, 1.001f); + } + } + + [Fact] + public void Project_CloseDoorway_NdcStaysWithinScreen_AndCoversScreen() + { + // The probe-confirmed void frame (2026-06-05): the chase eye is ~0.28 m from the front-door + // EXIT portal — well inside RetailChaseCamera's 1.0 m near plane — and looking through it. + // The door subtends the whole screen, but the old clip produced NDC like (10.2,-67.4) and + // ScreenPolygonClip reduced it to clip=0 (the void). After the homogeneous side-plane clip + // the NDC stays on-screen AND the door still covers the viewport (non-empty), not the void. + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1.0f, 5000f); // RetailChaseCamera + var viewProj = view * proj; + + // A 2 m x 2 m doorway 0.28 m in front of the eye, facing it. + var doorway = new[] + { + new Vector3(-1f, -1f, -0.28f), new Vector3(1f, -1f, -0.28f), + new Vector3(1f, 1f, -0.28f), new Vector3(-1f, 1f, -0.28f), + }; + var projected = PortalProjection.ProjectToNdc(doorway, Matrix4x4.Identity, viewProj); + Assert.True(projected.Length >= 3); + foreach (var v in projected) + { + Assert.InRange(v.X, -1.001f, 1.001f); + Assert.InRange(v.Y, -1.001f, 1.001f); + } + + var viewport = CellView.FullScreen().Polygons[0].Vertices; + var onScreen = ScreenPolygonClip.Intersect(projected, viewport); + Assert.True(onScreen.Length >= 3, "a doorway the eye looks through must cover the screen, not collapse to the void"); + } + [Fact] public void Project_PortalEyeIsAlmostTouching_StaysVisibleOnScreen() { From 9f95252d201e6471dfdb244d7785729a44c80d37 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 15:27:39 +0200 Subject: [PATCH 018/172] fix(render): flood the neighbour when the eye stands in an interior portal When the chase camera roots in a thin doorway cell and the eye stands in an interior portal opening (live capture: vestibule->room portal D=0.16m, proj=0), the 2D projection degenerates and the neighbour was culled (cells=1) -> only the thin cell drew -> bluish void / transparent ceiling. Retail's 3D clip imposes no constraint for a portal the eye is inside, so the neighbour is fully visible. When the clipped region is empty but the eye stands in the opening (EyeInsidePortalOpening: within 0.5m of the portal plane AND point-in-opening), flood the neighbour with the current view. Guarded so an off-screen degenerate portal stays culled (no #95 blowup; over-include is mesh-frustum-culled at draw). Visual-verified: cellar ceiling now solid. Band-aid for thin-cell-root coverage; likely superseded by the boom-stability + viewer-cell dead-zone + w=0 near-plane clip fix next session (reassess / maybe revert). 2 RED->GREEN tests; cyclic/hub termination guards unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/PortalVisibilityBuilder.cs | 95 +++++++++++++++++-- .../Rendering/PortalVisibilityBuilderTests.cs | 40 ++++++++ 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index dd636a04..b1fac06b 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -145,18 +145,36 @@ public static class PortalVisibilityBuilder // (ProjectToNdc preserves input winding; portal dat polygons may be CW). Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj); if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]"); - if (portalNdc.Length < 3) continue; - EnsureCcw(portalNdc); - - // Intersect the portal opening with every polygon of the current cell's view. var clippedRegion = new List(); - foreach (var vp in currentView.Polygons) + if (portalNdc.Length >= 3) { - var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices); - if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); + EnsureCcw(portalNdc); + // Intersect the portal opening with every polygon of the current cell's view. + foreach (var vp in currentView.Polygons) + { + var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices); + if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); + } } if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); - if (clippedRegion.Count == 0) continue; // portal not visible through this chain + + // R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the + // portal here. BUT if the eye is STANDING IN this portal's opening, the 2D projection has + // degenerated (the eye is in the doorway plane / within the near plane of the opening; the + // live capture saw the vestibule->room portal at D=0.16 m project to 0 verts). Retail's 3D + // portal clip imposes no constraint for a portal the eye is inside, so the neighbour is + // fully visible — substitute the CURRENT cell's view as the region so the flood reaches it + // (without this, rooting at a thin doorway cell drew only that cell -> the bluish void). + // EyeInsidePortalOpening (near-plane perp + point-in-opening) keeps a merely off-screen + // degenerate portal culled, so the visible set does not blow up (#95). Over-inclusion is + // otherwise safe: the neighbour mesh is frustum-culled per-vertex at draw time. + if (clippedRegion.Count == 0) + { + if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) + continue; // portal not visible through this chain, and the eye is not standing in it + foreach (var vp in currentView.Polygons) + clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone())); + } var portal = cell.Portals[i]; @@ -394,6 +412,67 @@ public static class PortalVisibilityBuilder return best == float.MaxValue ? 0f : MathF.Sqrt(best); } + // "Eye standing in the opening": the eye is within this perpendicular distance of a portal's + // plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase + // camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals + // the eye is merely facing from across a room (their projection is non-degenerate anyway). + private const float EyeStandingPerpDist = 0.5f; + + /// + /// True when the camera eye is "standing in" 's opening: within + /// of the portal plane AND its perpendicular projection onto + /// that plane falls inside the portal polygon. This is the case where the 2D portal projection + /// degenerates to empty (the eye is in the doorway plane) yet the neighbour is genuinely visible + /// — retail's 3D portal clip imposes no constraint there. Used only as the gate that lets such a + /// portal flood its neighbour with the current view; a degenerate portal the eye is NOT inside + /// (off-screen / across the room) returns false and stays culled, so the visible set cannot blow up. + /// + private static bool EyeInsidePortalOpening(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 eyeWorld) + { + if (localPoly == null || localPoly.Length < 3) return false; + var p0 = Vector3.Transform(localPoly[0], worldTransform); + var p1 = Vector3.Transform(localPoly[1], worldTransform); + var p2 = Vector3.Transform(localPoly[2], worldTransform); + var n = Vector3.Cross(p1 - p0, p2 - p0); + float nl = n.Length(); + if (nl < 1e-8f) return false; // degenerate polygon — no plane + n /= nl; + float perp = Vector3.Dot(n, eyeWorld - p0); + if (MathF.Abs(perp) > EyeStandingPerpDist) return false; // eye not close to the portal plane + + // In-plane 2D basis (u along the first edge, v = n × u). Project the eye + every vertex into + // it (the perpendicular component drops out of the dot products) and run a point-in-polygon test. + var u = p1 - p0; + float ul = u.Length(); + if (ul < 1e-8f) return false; + u /= ul; + var v = Vector3.Cross(n, u); + var rel = eyeWorld - p0; + var eye2 = new Vector2(Vector3.Dot(rel, u), Vector3.Dot(rel, v)); + var poly2 = new Vector2[localPoly.Length]; + for (int k = 0; k < localPoly.Length; k++) + { + var w = Vector3.Transform(localPoly[k], worldTransform) - p0; + poly2[k] = new Vector2(Vector3.Dot(w, u), Vector3.Dot(w, v)); + } + return PointInPoly2D(eye2, poly2); + } + + // Standard ray-crossing (even-odd) point-in-polygon test. + private static bool PointInPoly2D(Vector2 p, Vector2[] poly) + { + bool inside = false; + for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++) + { + var a = poly[i]; + var b = poly[j]; + if (((a.Y > p.Y) != (b.Y > p.Y)) && + (p.X < (b.X - a.X) * (p.Y - a.Y) / (b.Y - a.Y) + a.X)) + inside = !inside; + } + return inside; + } + /// /// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list + /// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index a648a072..18aae05e 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -58,6 +58,46 @@ public class PortalVisibilityBuilderTests $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); } + [Fact] + public void Build_EyeStandingInInteriorPortal_FloodsNeighbour() + { + // R1 void fix (2026-06-05): when the chase camera roots in a thin doorway cell and the eye is + // STANDING IN an interior portal opening, the live capture showed the vestibule->room portal at + // D=0.16 m projecting to 0 verts (proj=0), so the neighbour was wrongly culled (cells=1) and + // only the thin cell drew -> bluish void. Retail's 3D portal clip imposes no constraint for a + // portal the eye is inside, so the neighbour is fully visible. The flood MUST reach the neighbour. + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); + cam.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.3f, -0.03f)); // opening 3 cm in front — eye standing in it + var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); + room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); + var all = new Dictionary { [0x0001] = cam, [0x0002] = room }; + + var frame = Build(cam, all); + + Assert.True(frame.CellViews.ContainsKey(0x0002), + "eye standing in the doorway must flood the neighbour (degenerate projection was culling it -> void)"); + Assert.Contains(0x0002u, frame.OrderedVisibleCells); + } + + [Fact] + public void Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion() + { + // Guard against the fix over-flooding: a portal whose opening the eye is NOT standing in (3 cm + // in front but 2 m to the SIDE) also projects degenerate, but the eye is OUTSIDE the opening, so + // it must stay culled — otherwise the eye-in-doorway fix would blow up the visible set (#95) by + // flooding every degenerate-projecting portal regardless of where the eye actually is. + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); + cam.PortalPolygons.Add(Quad(2.0f, 0f, 0.3f, 0.3f, -0.03f)); // 2 m to the side — eye NOT in it + var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); + room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); + var all = new Dictionary { [0x0001] = cam, [0x0002] = room }; + + var frame = Build(cam, all); + + Assert.False(frame.CellViews.ContainsKey(0x0002), + "a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)"); + } + [Fact] public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() { From 9601ef39c3a1f53626f1f9085984e40a1e784242 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 15:31:17 +0200 Subject: [PATCH 019/172] docs: indoor flicker/void root cause (decomp + live cdb) + 3-part fix plan handoff Diagnosis session: the indoor bluish void + grey/texture flicker is visibility metastability at cell boundaries, not a missing flood (R1's per-cell DrawInside is built; the cellar seals). Confirmed by named-retail decomp AND a live cdb capture of retail (viewer_cell rock-stable: clean monotonic transitions, zero oscillation across 4916 samples). Retail stays stable via boom stability + a 0.2mm viewer-cell dead-zone + clip-space portal clipping; acdream diverges on all three. Handoff documents the root cause, the cdb evidence, and the prioritized 3-part retail-faithful fix (boom stability -> dead-zone -> w-space clip) with decomp anchors + a planning/implementation kickoff prompt. Adds the reusable retail viewer-cell cdb capture script and the superseding CLAUDE.md banner. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 20 +- ...-flicker-rootcause-and-fix-plan-handoff.md | 251 ++++++++++++++++++ tools/cdb/retail-viewer-cell.cdb | 17 ++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md create mode 100644 tools/cdb/retail-viewer-cell.cdb diff --git a/CLAUDE.md b/CLAUDE.md index cb58ee6d..9a72d3b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -763,7 +763,25 @@ H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven. **Currently working toward: M1.5 — Indoor world feels right** (resumed from 2026-05-20 baseline after Phase O ship). -**2026-06-05 — Render Residual A (camera collision) SHIPPED + user-kept; next = the CORE INSIDE RENDER (R1 completion) (READ THIS FIRST).** +**2026-06-05 (PM) — Indoor FLICKER + bluish VOID ROOT CAUSE CONFIRMED (decomp + live cdb); 3-part retail-faithful fix PLANNED (READ THIS FIRST).** +The "core inside render / cellar floor drops" framing below is **SUPERSEDED** by this session's diagnosis. +R1's per-cell `DrawInside` is already built and the cottage/cellar **seals** (user visual-verified). The +residual indoor **flicker (grey↔texture while standing still)** + **stable bluish void** are ONE root cause — +**visibility metastability at cell boundaries:** the 3rd-person camera **boom drifts at rest** +(`desiredBack 3.11→3.07`), walking the eye across a portal plane, and acdream re-resolves the **viewer cell** +fresh each frame with **no hysteresis** → it flips `0170↔0171` → the render (rooted at the viewer cell) +redraws two solves → flicker; and the 2D project-then-clip **degenerates** at close portals (`proj=0`) → grey +void. **Live cdb on retail CONFIRMS** retail's `viewer_cell` is rock-stable here (clean monotonic transitions, +ZERO oscillation across 4,916 samples) — retail holds the boom + uses a **0.2 mm cell dead-zone** + clips +portals **in clip-space**. **FIX (3 parts, retail-faithful, planned):** (1) camera boom stability +[`RetailChaseCamera`; `UpdateCamera` 0x456660] → kills the flicker trigger; (2) viewer-cell ±0.2 mm dead-zone +[`PhysicsCameraCollisionProbe.SweepEye`; `point_inside_cell_bsp` 0x53c1f0]; (3) w-space (w=0) portal clip +[`PortalProjection`/`PortalVisibilityBuilder`; `GetClip` 0x5a4320 / `polyClipFinish` 0x6b6d00]. Two partial +fixes committed: `5f596f2` (NDC side-plane clip — KEEP), `9f95252` (eye-in-portal flood — reassess/revert). +Baseline App 183p / Core 1326p-4f-1s. **CANONICAL PICKUP:** +[`docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`](docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md). + +**2026-06-05 — Render Residual A (camera collision) SHIPPED + user-kept; next = the CORE INSIDE RENDER (R1 completion) (SUPERSEDED 2026-06-05 PM — see the banner above; the cellar IS sealed, the real bug is the boundary flicker/void).** Residual A = a verbatim port of retail `SmartBox::update_viewer` (pc:92761): the indoor sweep's start cell is seated at the head-PIVOT via `AdjustPosition` (pc:280009) → `find_visible_child_cell` (pc:311397), plus the two fallbacks + cellId==0 snap-to-player. Commits `0ffc3f5` (spec) / `5177b54` (Core primitives) diff --git a/docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md b/docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md new file mode 100644 index 00000000..0adf8756 --- /dev/null +++ b/docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md @@ -0,0 +1,251 @@ +# Handoff — Indoor flicker/void ROOT CAUSE confirmed (decomp + live cdb); 3-part retail-faithful fix planned — 2026-06-05 (PM) + +> **Canonical pickup for the next (fix) session. Read this FIRST.** This session did the diagnosis +> the previous "core inside render (R1)" handoff asked for, and it landed somewhere different than that +> handoff predicted. The indoor **bluish void + grey/texture flicker** is NOT a missing per-cell flood +> port — R1's per-cell `DrawInside` is built and the cellar/ceiling seal correctly. The residual is +> **camera/viewer-cell instability at cell boundaries**, confirmed by both the named-retail decomp AND a +> live cdb capture of retail. The fix is a **3-part retail-faithful port** (camera boom stability + +> viewer-cell dead-zone + w-space portal clip), de-risked and ready to plan + implement. +> Branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; launch logs are UTF-16. + +--- + +## 0. TL;DR + +- **The premise we started with was stale.** "Cellar floor drops / R1 incomplete" — actually R1's per-cell + `DrawInside` + binary inversion already exist (commit `c4fd711`, 2026-06-02) and the cellar **is sealed** + (user visual-verified T1 this session). The flood reaches the cellar; the shell draws. +- **The real bug is two faces of ONE root cause — visibility metastability at cell boundaries:** + 1. **Flicker (grey↔texture) at a "stationary" position** = the **viewer (camera) cell flips per-frame** + `0170↔0171` because the 3rd-person camera **boom drifts** (`desiredBack 3.11→3.07` while the player + stands still), walking the eye across a portal plane, and acdream re-resolves the viewer cell fresh + each frame with **no hysteresis**. The render roots at the viewer cell, so it redraws two different + solves → flicker. + 2. **Stable bluish void** = when the eye is firmly in/at a thin/transition cell, the portal flood + **degenerates** because acdream projects-to-NDC-then-2D-clips instead of clipping in clip-space, so a + close/grazing portal drops (`proj=0`) → no terrain / neighbour not flooded → grey. +- **CONFIRMED by live cdb on retail** (this is the payoff): retail's `viewer_cell` is **rock-stable** at + the same Holtburg-cottage boundary — clean single monotonic transitions, **zero oscillation** across + 4,916 samples; retail rests the camera in the *substantial* cell, never lingering in the thin doorway + cell. acdream's `[flap-sweep]` flips `0170↔0171` at the same spot. Retail is stable, acdream is not — + exactly as the decomp predicted. +- **The fix (3 parts, prioritized) is retail-faithful and anchored to specific decomp functions.** §4. +- **Two partial fixes shipped this session** (`5f596f2` NDC frustum side-plane clip — keep; + `9f95252` eye-in-portal flood — band-aid, likely superseded → reassess/revert). §3. +- **Test baseline:** App **183 pass / 0 fail** (179 + 4 new). Core **1326 pass / 4 fail (documented) / + 1 skip**. Branch HEAD after this handoff commit. Build green. + +--- + +## 1. The session arc (so you don't repeat it) + +1. Read the "core inside render (R1)" handoff. **Discovered R1's inversion + per-cell `DrawInside` already + exist and are live** (`c4fd711`/`4b75c68`/`cf85ea4`, 2026-06-02; not reverted). So this was a *debug* + task, not a *port* task. +2. **Visual gate (user):** the cellar (T1) is **sealed**. The real symptoms are at **transitions**: a + bluish **void flap** exiting the building (screenshot), a **grey flash** cellar→room, **outdoor + content through the ground** looking out, and a **stationary flicker** (textures alternate grey↔texture + while standing still). +3. **Evidence-first (probes `ACDREAM_PROBE_FLAP/_VIS/_SHELL/_CELL`):** + - Refuted the prior handoff's hypothesis ("flood doesn't reach the cellar") — rooted at the room, the + flood **does** reach the cellar (`[vis] root=0171 ids=[...,0174]`). + - Found the void is `terrain=Skip` / `proj=0` on exit/interior portals when the eye is close + (`[flap] p->0xFFFF D=-0.28 proj=4 clip=0`; later `p1->0x0171 D=0.16 proj=0`). + - Found the **flicker** is the **viewer cell flipping** at a knife-edge boundary: in a held pose the + `[flap-sweep] viewerCell` flips `0170↔0171` as `D` crosses `0.00`, while the **player stands still** + and the boom **drifts** (`desiredBack 3.11→3.07`). +4. **Decomp spike (3 parallel agents + 1 verified-myself crux):** mapped how retail stays stable. §2. +5. **Live cdb on retail** (matching v11.4186 binary, PDB-paired): captured retail's `viewer_cell` across + the same crossings → **clean, no oscillation**. §2.4. Confirms the fix direction. + +--- + +## 2. ROOT CAUSE — retail stays stable via THREE mechanisms; acdream diverges on each + +### 2.1 Camera boom is a stable spring (Q3) — `CameraManager::UpdateCamera` +- Retail's boom vector `CameraManager::viewer_offset` (default `y = -2.5 m`) is **fixed** (changes only on + zoom keys). `UpdateCamera` (≈`0x00456660`, lerp body `0x00456d0d`) lerps the camera *position* toward + `pivot + viewer_offset` with stiffness, and **snaps to the current position when within 0.0004 m** → holds + a constant fixed point at rest. +- **The collided eye is firewalled from the desired position:** `set_viewer(…, arg3=0)` (the normal success + path, `update_viewer` pc≈92870) writes `this->viewer` (rendered eye) but **NOT** `viewer_sought_position`. + So the collision result never feeds back into the desired boom. +- `PlayerPhysicsUpdatedCallback` (`0x00452d60`, pc:91836) computes `viewer_sought_position = UpdateCamera(current_viewer)`. +- **acdream diverges:** `RetailChaseCamera` desired boom **drifts at rest** (`desiredBack 3.11→3.07`). + Hypotheses (verify in code): the collided eye is fed back into the desired (no firewall), and/or no + convergence snap. **This drift is the flicker trigger** (walks the eye across the boundary). + +### 2.2 Viewer cell is sticky via a 0.2 mm dead-zone (Q1) — VERIFIED MYSELF +- `SmartBox::update_viewer` (`0x00453ce0`, pc:92761): `viewer_cell = sphere_path.curr_cell` after the + collision sweep, which **starts from the stable player cell** each frame (`cell_1` = player cell or + `AdjustPosition`-seated). +- `SPHEREPATH::init_path` (`0x0050ce20`, pc:274370): `curr_cell = arg2` (the start cell). +- The sweep updates the cell only on a **definite** crossing: `check_other_cells` (`0x0050ae50`, pc:272717) + → `find_cell_list` → `check_cell`; `validate_transition` (`0x0050aa70`) promotes `curr_cell = check_cell` + (pc:272608) only when the cell/pos actually changed, else **restores `curr_cell`**. +- **The dead-zone (VERIFIED at pc:325513/325522):** `BSPNODE::point_inside_cell_bsp` (`0x0053c1f0`) uses + `0.000199999995f` (≈0.2 mm) symmetrically — a point within ±0.2 mm of a splitting plane belongs to + **neither** cell, so at a boundary graze `check_cell` is null and `curr_cell` stays at the start cell. +- **acdream diverges:** `RetailChaseCamera.ViewerCellId = swept.ViewerCellId` is re-resolved fresh **every + frame** with **no dead-zone** ("graph-tracked, deterministic, NO grace frames" — the comment) → flips at + the boundary. + +### 2.3 Portal clip is homogeneous (w-space), before the divide (Q2) — `GetClip`/`polyClipFinish` +- `PView::GetClip` (`0x005a4320`, pc:432344) projects the portal then calls `ACRender::polyClipFinish` + (`0x006b6d00`, pc:702749), which **clips against the near plane (w=0) in clip-space, generating synthetic + edge vertices, BEFORE the perspective divide** — so a close portal never blows up to garbage NDC. +- `PView::InitCell` (`0x005a4b70`, pc:432896) **side-test culls** in-plane/back-facing portals (same 0.2 mm + band, pc≈432936) before any projection. +- The flood is substantially **root-invariant** for adjacent cells (both seeded full-screen; side-test is + symmetric). +- **acdream diverges:** projects-to-NDC then 2D-clips → degenerates at grazing/close angles (`proj=0` → + portal dropped → grey void / neighbour not flooded). This session's commit `5f596f2` added the eye-plane + + side-plane clip (partial); the missing piece is the **w=0 near-plane clip with synthetic verts** + the + side-test dead-band. + +### 2.4 LIVE CDB CONFIRMATION (retail v11.4186, PDB-paired) — the payoff +Captured `SmartBox::viewer_cell` (`viewer.objcell_id`) at the Holtburg cottage while passing inside↔outside ++ the stairs + standing still. Run-length-encoded camera-cell sequence (4,916 samples): +``` +0xa9b40032 ×3360 → 0031 ×173 → 0170 ×8 → 0171 ×134 → 0170 ×14 → 0031 ×129 +→ 0170 ×7 → 0171 ×139 → 0170 ×15 → 0031 ×110 → 0170 ×7 → 0171 ×167 → … (clean repeats) +``` +- **Every crossing is a clean, single, monotonic pass** (outside `0031/0032` → vestibule `0170` brief + 7–15-sample pass-through → room `0171`, and back). **ZERO `0170↔0171` oscillation** anywhere. +- Standing still, retail rests in the **substantial** cells (room `0171` ×134–197, outside long runs), + **never lingering in the thin vestibule `0170`**. +- **Contrast acdream:** `[flap-sweep] viewerCell` flips `0170↔0171` per-frame at the same boundary. +- **Conclusion:** retail's viewer cell is stable (boom holds + 0.2 mm dead-zone + sweep-from-player-cell); + acdream's is not. No surprises — the fix is de-risked. + +--- + +## 3. What shipped this session (committed; partial) + +| SHA | What | Keep? | +|---|---|---| +| `5f596f2` | `PortalProjection.ProjectToNdc` clips eye + 4 frustum **side planes** in clip-space before the divide (replaces the 2026-06-03 `MinW`-only workaround). Bounds NDC to the screen. | **KEEP.** Real correctness, retail-consistent (partial of §2.3). | +| `9f95252` | `PortalVisibilityBuilder` floods the neighbour when the eye **stands in** an interior portal (`EyeInsidePortalOpening`). Fixed the cellar **ceiling** (visual-verified). | **REASSESS / likely REVERT.** A coverage band-aid for the thin-cell-root case; the §4 boom + dead-zone keep the camera out of thin cells, and the w=0 clip handles close portals — this may become unnecessary or over-include. Easy `git revert 9f95252`. | + +Neither is the flicker fix. Both green (App 183), Core baseline held (1326/4/1). + +--- + +## 4. THE FIX — 3-part retail-faithful port (prioritized). Plan, then implement TDD. + +### Part 1 (HIGHEST leverage) — Camera boom stability → kills the flicker trigger +- **Goal:** acdream's desired boom settles and **holds** at rest (no drift). Match `UpdateCamera`: desired + position derived each frame from a **fixed** boom offset + the player pivot; **firewall the collided eye** + out of the desired chain; add the **convergence snap** (return current when within ~0.0004 m). +- **acdream targets:** `src/AcDream.App/Rendering/RetailChaseCamera.cs` (the `_dampedEye` / `desiredBack` / + the lerp + where the collided `swept.Eye` is consumed). Verify whether `swept.Eye` feeds the next-frame + desired (the drift hypothesis) and whether a snap exists. +- **Anchors:** `CameraManager::UpdateCamera` `0x00456660` (snap ~`0x00456d0d` region), `PlayerPhysicsUpdatedCallback` + `0x00452d60` (pc:91836), `set_viewer` arg3=0 firewall (`update_viewer` pc≈92870). +- **Verify:** with the boom stable, the `[flap-sweep] eyeBack/desiredBack` is flat at rest and the eye stops + grazing the boundary. + +### Part 2 — Viewer-cell dead-zone hysteresis → belt-and-suspenders for the flicker +- **Goal:** acdream's camera-sweep viewer-cell resolution doesn't flip on a sub-mm/boundary graze. Port the + retail dead-zone: a point within ±0.2 mm of a portal plane belongs to neither cell → keep the prior/start + (player) cell. +- **acdream targets:** `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs` (`SweepEye`) and the + Core cell-resolution it calls (`CellTransit.FindVisibleChildCell` / the point-in-cell test). Ensure the + sweep starts from the player cell and only changes `viewer_cell` on a definite crossing. +- **Anchors:** `point_inside_cell_bsp` `0x0053c1f0` (0.000199999995f, pc:325513/325522), `init_path` pc:274370, + `validate_transition` pc:272608, `check_other_cells` pc:272717. + +### Part 3 — w-space portal clip robustness → kills the *stable* grey void +- **Goal:** extend `5f596f2` to a true **near-plane (w=0) clip with synthetic edge vertices before the + divide** + the **InitCell side-test dead-band** that culls in-plane/back-facing portals before projection. +- **acdream targets:** `src/AcDream.App/Rendering/PortalProjection.cs` + `PortalVisibilityBuilder.cs` + (the side test `CameraOnInteriorSide`). After this, **reassess `9f95252`** (the eye-in-portal flood may be + redundant → revert). +- **Anchors:** `GetClip` `0x005a4320` (pc:432344), `polyClipFinish` `0x006b6d00` (pc:702749, the w=0 clip), + `InitCell` `0x005a4b70` (pc:432896 side-test). + +**Order:** Part 1 → visual gate → Part 2 → Part 3 → reassess `9f95252`. Each is independently verifiable. + +--- + +## 5. KEEP / DON'T + +**KEEP:** +- R1 per-cell `DrawInside` + the binary inversion (`c4fd711`) — built and correct; the cellar seals. +- Residual A (camera collision, `update_viewer` port) — the viewer cell is *accurate*; we're stabilizing it, + not removing it. +- Commit `5f596f2` (NDC side-plane clip). +- The two-camera-ish reality: eye drives projection; the fix makes the *viewer cell* stable (boom + dead-zone), + matching retail (`is_player_outside` decides; `DrawInside(viewer_cell)` roots). + +**DON'T:** +- Don't re-attempt "the flood doesn't reach the cellar" — refuted (`[vis]` shows it does). +- Don't add a render-side debounce/grace-period for the flicker — it's a **membership/visibility stability** + bug; fix the *input* (boom + dead-zone), not the render (memory: render-downstream-of-membership). +- Don't switch the render root to the *player* cell — retail roots `DrawInside` at the *viewer* cell; the + fix is to make the viewer cell *stable*, not to change which cell roots. +- Don't put a `;` inside a cdb `$$` comment (it splits into a command — bit me this session; use `*` comments). + +--- + +## 6. APPARATUS (committed / ready) + +- **Probes** (all live): `ACDREAM_PROBE_FLAP` (`[flap]`/`[flap-cam]`/`[flap-sweep]`), `ACDREAM_PROBE_VIS` + (`[vis]`), `ACDREAM_PROBE_SHELL` (`[shell]`), `ACDREAM_PROBE_CELL` (`[cell-transit]`). +- **cdb script** `tools/cdb/retail-viewer-cell.cdb` — samples retail `SmartBox::viewer_cell` ~6/sec, + auto-`.detach` after 6000 hits. Binary verified MATCH via `tools/pdb-extract/check_exe_pdb.py`. + Re-run pattern + RLE analysis are in this session's transcript (PowerShell `Select-String` on + `retail-viewer-cell.log`). **Lesson:** per-frame bp + `dt`/`.printf` is heavy but survived here (retail + intact); keep samples sparse. `qd` is ignored in bp actions — use `.detach`. +- **TTD** is available (`tools/ttd-record.ps1` / `tools/ttd-query.ps1`) if a lower-overhead capture is needed. + +--- + +## 7. STATE +- Branch `claude/thirsty-goldberg-51bb9b`. HEAD: this handoff's docs commit (after `9f95252`). No push (ask first). +- Build green. App **183 / 0**. Core **1326 / 4 (documented: 2× DoorBugTrajectoryReplay LiveCompare, + BSPStepUpTests.D4, DoorCollisionApparatus) / 1 skip**. +- Running the client: see CLAUDE.md "Running the client"; `+Acdream` spawns in the Holtburg cottage. + +--- + +## 8. KICKOFF PROMPT (copy-paste for the next session) + +``` +Continue acdream M1.5 indoor render: fix the boundary FLICKER + stable bluish VOID with the 3-part +retail-faithful port that the 2026-06-05 spike confirmed. ROOT CAUSE (decomp + live cdb, both done): +the indoor flicker/void is VISIBILITY METASTABILITY at cell boundaries, not a missing flood — R1's +per-cell DrawInside is built and the cellar seals. Retail stays stable via three mechanisms; acdream +diverges on each. Branch claude/thirsty-goldberg-51bb9b (do NOT branch/worktree; do NOT push without +asking; NEVER git stash/gc). PowerShell on Windows; launch logs are UTF-16. + +READ FIRST (in order): +1. docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md (THIS handoff — root + cause §2, cdb confirmation §2.4, the 3-part fix §4, KEEP/DON'T §5). +2. memory: reference_render_pipeline_state.md, feedback_render_downstream_of_membership.md, + feedback_render_one_gate.md, project_camera_visibility_coupling.md. +3. The decomp anchors cited in §2/§4 (named-retail) for each part you implement. + +THE FIX (plan first, then implement TDD; each part independently visual-verified): + Part 1 (highest leverage) — Camera boom stability (RetailChaseCamera): desired boom settles + HOLDS at + rest; firewall the collided eye out of the desired chain; add the convergence snap. Anchors: + CameraManager::UpdateCamera 0x00456660, PlayerPhysicsUpdatedCallback 0x00452d60, set_viewer arg3=0. + Part 2 — Viewer-cell dead-zone (PhysicsCameraCollisionProbe.SweepEye / Core cell resolution): ±0.2 mm + dead-zone so a graze keeps the player/start cell. Anchors: point_inside_cell_bsp 0x0053c1f0 + (0.000199999995f), validate_transition pc:272608, init_path pc:274370. + Part 3 — w-space portal clip (PortalProjection/PortalVisibilityBuilder): near-plane (w=0) clip with + synthetic verts before the divide + InitCell side-test dead-band; then reassess/revert the + eye-in-portal flood band-aid (commit 9f95252). Anchors: GetClip 0x005a4320, polyClipFinish 0x006b6d00, + InitCell 0x005a4b70. + +START by using superpowers:writing-plans (or brainstorming if a part's shape is unclear) to turn §4 into a +step plan with per-part acceptance + visual gates, THEN implement Part 1 first via TDD. + +DON'T (§5): no render-side debounce for the flicker (fix the boom/cell input); don't switch the render +root to the player cell (retail roots DrawInside at the viewer cell — stabilize it instead); don't reopen +"flood doesn't reach the cellar" (refuted). + +TEST BASELINE: App 183 pass / 0 fail. Core 1326 pass / 4 fail (documented) / 1 skip. Build green. +This session committed 5f596f2 (NDC side-plane clip — KEEP) + 9f95252 (eye-in-portal flood — reassess). +``` diff --git a/tools/cdb/retail-viewer-cell.cdb b/tools/cdb/retail-viewer-cell.cdb new file mode 100644 index 00000000..0634df9e --- /dev/null +++ b/tools/cdb/retail-viewer-cell.cdb @@ -0,0 +1,17 @@ +* Retail viewer-cell stability capture (2026-06-05) +* Confirms whether retail SmartBox viewer_cell flips at a portal boundary +* or stays stable. Samples viewer.objcell_id ~6/sec to avoid lagging retail. +* Auto-detaches after 6000 hits via .detach (cdb ignores qd inside bp actions) +* NOTE never put a semicolon inside a comment line, it splits into a command + +.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\retail-viewer-cell.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +x acclient!SmartBox::update_viewer + +r $t0 = 0 +bp acclient!SmartBox::update_viewer "r $t0 = @$t0 + 1; .if (@$t0 % 5 == 1) { .printf \"[vc] hit=%d eyeCell=\", @$t0; dt acclient!SmartBox @ecx viewer.objcell_id }; .if (@$t0 >= 6000) { .detach } .else { gc }" + +g From d2212cfaea4ee7bcbca3e95a43b0bd0f30e63dc8 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 15:56:04 +0200 Subject: [PATCH 020/172] =?UTF-8?q?fix(render):=20Part=201=20=E2=80=94=20c?= =?UTF-8?q?amera=20boom=20convergence=20snap=20(kills=20the=20at-rest=20vi?= =?UTF-8?q?ewer-cell=20flicker=20trigger)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port retail CameraManager::UpdateCamera's convergence snap (0x00456fcd): once the per-frame lerp step is below 0.0004 m AND the rotation within 0.000199999995, freeze the damped eye at an exact fixed point instead of Vector3.Lerp's endless sub-mm asymptote. The drift was walking the 3rd-person eye across the vestibule/room portal plane at rest, flipping the per-frame viewer-cell resolve 0170<->0171 -> the indoor grey/texture flicker. The collided-eye firewall (separate publishedEye local) is already present. Adds ApplyConvergenceSnap static (TDD: 3 unit tests + 1 integration freeze test) + SnapEpsilon/RotCloseEpsilon. App suite 183 -> 187, all green. Plan: docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-05-indoor-viewer-cell-flicker-fix.md | 552 ++++++++++++++++++ .../Rendering/RetailChaseCamera.cs | 40 +- .../Rendering/RetailChaseCameraTests.cs | 98 ++++ 3 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md diff --git a/docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md b/docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md new file mode 100644 index 00000000..9dc111cf --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md @@ -0,0 +1,552 @@ +# Indoor Viewer-Cell Flicker + Bluish-Void Fix — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Kill the indoor render **flicker (grey↔texture at rest)** and the **stable bluish void** at cottage cell boundaries by porting the three retail mechanisms that keep retail's `viewer_cell` rock-stable — camera-boom convergence snap, viewer-cell dead-zone, and w-space portal clip — confirmed root cause by decomp + live cdb (`docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`). + +**Architecture:** Three independent, retail-faithful ports, each TDD'd and **independently visual-verified** in this order (highest leverage first): +1. **Camera boom stability** (`RetailChaseCamera`) — add the retail `UpdateCamera` convergence snap so the damped eye reaches an **exact fixed point** at rest instead of dithering sub-millimetre forever. Removes the *trigger* (the eye walking across a portal plane). The collided-eye firewall is already present (verified). +2. **Viewer-cell dead-zone** (`BSPQuery.PointInsideCellBsp`) — port retail's symmetric **±0.000199999995 m** dead-zone so a point grazing a splitting plane belongs to *neither* child → membership stays sticky. Belt-and-suspenders for the flicker; shared Core primitive (also used by physics), so the full Core suite gates it. +3. **w-space portal clip** (`PortalProjection` / `PortalVisibilityBuilder`) — verify the void is gone after 1+2, port the InitCell side-test dead-band for faithfulness, then **reassess/revert** the `9f95252` eye-in-portal flood band-aid. + +**Tech Stack:** C# .NET 10, `System.Numerics`, xUnit. No new dependencies. Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +**DON'T (from handoff §5):** +- No render-side debounce/grace-period for the flicker — fix the *input* (boom + cell), never the render. +- Don't switch the render root to the *player* cell — retail roots `DrawInside` at the *viewer* cell; make the viewer cell *stable*, don't change which cell roots. +- Don't reopen "the flood doesn't reach the cellar" — refuted. +- Don't revert Residual A (the `update_viewer` camera-collision port) — it made the viewer cell *accurate*; we're stabilising it. + +**Test baseline (must hold):** App **183 pass / 0 fail**. Core **1326 pass / 4 fail (documented: 2× DoorBugTrajectoryReplay LiveCompare, BSPStepUpTests.D4, DoorCollisionApparatus) / 1 skip**. Build green. + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `src/AcDream.App/Rendering/RetailChaseCamera.cs` | Modify | Part 1: add `SnapEpsilon`/`RotCloseEpsilon` consts + `ApplyConvergenceSnap` static + wire it into `Update`'s damping branch. | +| `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` | Modify | Part 1: unit test for `ApplyConvergenceSnap` + integration "boom freezes at rest" test. | +| `src/AcDream.Core/Physics/BSPQuery.cs` | Modify | Part 2: add the ±0.000199999995 dead-zone to `PointInsideCellBsp` (3-way classify). | +| `tests/AcDream.Core.Tests/Physics/PointInsideCellBspDeadZoneTests.cs` | Create | Part 2: RED→GREEN dead-zone tests + a `FindVisibleChildCell` stickiness test. | +| `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | Modify (Part 3, gated) | Part 3: port InitCell side-test dead-band into `CameraOnInteriorSide`; reassess/remove `EyeInsidePortalOpening` flood (`9f95252`). | +| `tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs` | Modify (Part 3, gated) | Part 3: close-portal robustness regression if a residual void persists. | + +Each part is one or more commits, each ending in a **VISUAL GATE** (the user looks at the running client). Parts 2 and 3 only start after the previous part's visual gate passes. + +--- + +## Part 1 — Camera boom convergence snap (highest leverage) + +**Root cause (decomp-confirmed):** `RetailChaseCamera.Update` lerps `_dampedEye` toward `targetEye` with `Vector3.Lerp` every frame (`RetailChaseCamera.cs:149`). `Vector3.Lerp` is asymptotic — it never reaches an exact fixed point, so the eye makes a tiny sub-millimetre step *every frame forever*. At rest that walks the eye across the vestibule/room portal plane → the per-frame viewer-cell resolve flips `0170↔0171` → the render redraws two solves → flicker. Retail's `CameraManager::UpdateCamera` (`0x00456660`) has a **convergence snap** at `0x00456fcd`: after interpolating, if the translation step `< 0.0004 m` (= `2 × 0.000199999995`, `0x00456fe1`) AND the rotation is within `0.000199999995` (`0x00456fdd`, `Frame::close_rotation`), it returns the input unchanged (`Position::Position(__return, ebx_1)`) — an exact fixed point. + +**The collided-eye firewall is already present** — `Update` collides into a separate `publishedEye` local and never writes `_dampedEye` (`RetailChaseCamera.cs:162-172`, comment at `:153-161`). So Part 1 is ONLY the snap. + +**Acceptance:** +- New `ApplyConvergenceSnap` static freezes when both deltas are sub-epsilon, else returns the candidate. +- After convergence with a constant pose, two consecutive `Update` frames produce a **bit-identical** `Position` (collision off). +- All existing `RetailChaseCameraTests` stay green (esp. `SecondUpdate_LerpsTowardTarget` — step 0.75 ≫ epsilon, no snap). +- **VISUAL GATE 1:** at the Holtburg cottage vestibule/room boundary, standing still, the `[flap-sweep] desiredBack` value holds flat (no `3.11→3.07` drift) and the grey↔texture flicker is gone or sharply reduced. + +### Task 1.1: Unit-test the convergence-snap helper (RED) + +**Files:** +- Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` + +- [ ] **Step 1: Write the failing test.** Append these tests to the class (after the existing `Update_CollisionDoesNotCorruptDampedState`, before the closing brace): + +```csharp + // ── Convergence snap (Part 1: kills the at-rest boom drift) ──────── + + [Fact] + public void ConvergenceSnap_StepBelowEpsilon_FreezesAtCurrent() + { + // Both the translation step and the rotation step are below the retail snap + // thresholds (0.0004 m / 0.0002) → freeze: return the CURRENT damped state, + // not the candidate. This is the exact fixed point retail's UpdateCamera reaches. + var damped = new Vector3(5f, 6f, 7f); + var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f)); + var candidate = damped + new Vector3(0.0001f, 0f, 0f); // 0.1 mm step < 0.4 mm + var candFwd = forward; // no rotation step + + var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd); + + Assert.True(frozen); + Assert.Equal(damped, eye); // exact — returns the input, freezing the drift + Assert.Equal(forward, fwd); + } + + [Fact] + public void ConvergenceSnap_TranslationStepAboveEpsilon_ReturnsCandidate() + { + var damped = new Vector3(5f, 6f, 7f); + var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f)); + var candidate = damped + new Vector3(0.01f, 0f, 0f); // 1 cm step ≫ 0.4 mm + var candFwd = forward; + + var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd); + + Assert.False(frozen); + Assert.Equal(candidate, eye); // still converging → apply the lerp step + Assert.Equal(candFwd, fwd); + } + + [Fact] + public void ConvergenceSnap_RotationStepAboveEpsilon_ReturnsCandidate() + { + // Translation has converged but the heading is still turning — retail does NOT + // freeze unless BOTH are close (it returns the interpolated frame). So a small + // translation step must NOT freeze while the forward is still rotating. + var damped = new Vector3(5f, 6f, 7f); + var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f)); + var candidate = damped + new Vector3(0.0001f, 0f, 0f); // sub-epsilon translation + var candFwd = Vector3.Normalize(new Vector3(1f, 0.05f, 0f)); // ~0.05 rad turn ≫ 0.0002 + + var (_, _, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd); + + Assert.False(frozen); + } +``` + +- [ ] **Step 2: Run the test to verify it fails (compile error — method doesn't exist).** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~ConvergenceSnap"` +Expected: **FAIL** — build error `'RetailChaseCamera' does not contain a definition for 'ApplyConvergenceSnap'`. + +### Task 1.2: Implement the convergence-snap helper + constants (GREEN) + +**Files:** +- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs` + +- [ ] **Step 1: Add the snap constants.** Immediately after the `DistanceMin/Max/PitchMin/Max` const block (after `RetailChaseCamera.cs:78`, the `public const float PitchMax = 1.4f;` line), add: + +```csharp + + // Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp + // acclient_2013_pseudo_c.txt, 0x00456fcd–0x00457035). SnapEpsilon = 2 × + // 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail + // freezes the boom at an exact fixed point (0x00456fe1). RotCloseEpsilon = + // 0.000199999995 — the Frame::close_rotation tolerance (0x00456fdd). Without the + // snap, Vector3.Lerp asymptotes forever and the boom drifts at rest, walking the eye + // across a portal plane and flipping the viewer cell → the indoor flicker. + private const float SnapEpsilon = 0.000199999995f * 2f; + private const float RotCloseEpsilon = 0.000199999995f; +``` + +- [ ] **Step 2: Add the `ApplyConvergenceSnap` static.** Add this method just after `ComputeDampingAlpha` (after `RetailChaseCamera.cs:370`, the closing brace of `ComputeDampingAlpha`): + +```csharp + + /// + /// Retail CameraManager::UpdateCamera convergence snap (decomp 0x00456fcd). + /// After the per-frame lerp, if the translation step from + /// to is below AND the + /// rotation step is below , retail returns the input + /// position unchanged — an exact fixed point. Returns frozen=true with the + /// current state in that case; otherwise frozen=false with the candidate. + /// Both conditions are required (retail couples origin + rotation in the snap test), + /// so the boom keeps converging while the heading is still turning. + /// + internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap( + Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward) + { + bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon; + bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon; + if (translationConverged && rotationConverged) + return (dampedEye, dampedForward, true); // freeze: exact fixed point + return (candidateEye, candidateForward, false); + } +``` + +- [ ] **Step 3: Run the unit tests to verify they pass.** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~ConvergenceSnap"` +Expected: **PASS** (3 tests). + +### Task 1.3: Wire the snap into `Update` + integration test + +**Files:** +- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs` +- Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` + +- [ ] **Step 1: Write the failing integration test.** Append to `RetailChaseCameraTests`: + +```csharp + [Fact] + public void Update_AtRestAfterConvergence_BoomFreezesAtExactFixedPoint() + { + // The retail UpdateCamera snap freezes the boom at an exact fixed point once the + // per-frame step falls below ~0.4 mm. Without it, Vector3.Lerp asymptotes forever + // — the eye dithers sub-millimetre every frame and walks across the portal plane, + // flipping the viewer cell (the indoor flicker). Hold a pose DIFFERENT from the + // init pose so the boom has to converge over many frames; with collision OFF + // (Position == _dampedEye), two consecutive post-convergence frames must be + // BIT-IDENTICAL. (At frame 120, α≈0.075, displacement ~7 m, the un-snapped step is + // ~5e-5 m ≈ tens of float ULP — distinguishably nonzero — so this is a real RED.) + bool savedAlign = CameraDiagnostics.AlignToSlope; + bool savedColl = CameraDiagnostics.CollideCamera; + float savedT = CameraDiagnostics.TranslationStiffness; + float savedR = CameraDiagnostics.RotationStiffness; + try + { + CameraDiagnostics.AlignToSlope = false; // deterministic heading + CameraDiagnostics.CollideCamera = false; // Position == _dampedEye + CameraDiagnostics.TranslationStiffness = 0.45f; + CameraDiagnostics.RotationStiffness = 0.45f; + + var cam = new RetailChaseCamera { Distance = 2.61f, Pitch = 0.291f }; + + // Frame 1 at pose A: init snaps the damped eye to A's target. + cam.Update(Vector3.Zero, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f); + + // Hold pose B for many frames → the boom lerps A's target → B's target. + var posB = new Vector3(5f, 5f, 0f); + for (int i = 0; i < 120; i++) + cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f); + + Vector3 a = cam.Position; + cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f); + Vector3 b = cam.Position; + + Assert.Equal(a, b); // exact — frozen, not dithering + } + finally + { + CameraDiagnostics.AlignToSlope = savedAlign; + CameraDiagnostics.CollideCamera = savedColl; + CameraDiagnostics.TranslationStiffness = savedT; + CameraDiagnostics.RotationStiffness = savedR; + } + } +``` + +- [ ] **Step 2: Run it to verify it fails.** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~BoomFreezesAtExactFixedPoint"` +Expected: **FAIL** — `Assert.Equal()` failure, `a` and `b` differ by ~5e-5 m (the un-snapped asymptotic step). + +- [ ] **Step 3: Wire the snap into the damping branch.** In `RetailChaseCamera.Update`, replace the `else` branch of the `if (!_initialised)` block (`RetailChaseCamera.cs:145-151`): + +```csharp + else + { + float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt); + float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt); + _dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha); + _dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha)); + } +``` + +with: + +```csharp + else + { + float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt); + float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt); + Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha); + Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha)); + + // Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed + // point once the lerp step is sub-epsilon, instead of dithering forever. This is + // the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon. + (_dampedEye, _dampedForward, _) = + ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward); + } +``` + +- [ ] **Step 4: Run the integration test + the full RetailChaseCamera suite to verify pass + no regression.** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"` +Expected: **PASS** (all existing + 4 new). Confirm `SecondUpdate_LerpsTowardTarget` and `Update_CollisionDoesNotCorruptDampedState` still pass. + +### Task 1.4: Full build + test + commit Part 1 + +- [ ] **Step 1: Build.** Run: `dotnet build`. Expected: green. +- [ ] **Step 2: Full App suite.** Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj`. Expected: **187 pass / 0 fail** (183 baseline + 4 new). +- [ ] **Step 3: Commit.** + +```bash +git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +git commit -m "fix(render): Part 1 — camera boom convergence snap (kills the at-rest viewer-cell flicker trigger) + +Port retail CameraManager::UpdateCamera's convergence snap (0x00456fcd): +once the per-frame lerp step is below 0.0004 m AND the rotation within +0.000199999995, freeze the damped eye at an exact fixed point instead of +Vector3.Lerp's endless sub-mm asymptote. The drift was walking the 3rd-person +eye across the vestibule/room portal plane at rest, flipping the per-frame +viewer-cell resolve 0170<->0171 -> the indoor grey/texture flicker. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +- [ ] **Step 4: VISUAL GATE 1 — STOP.** Launch the client (CLAUDE.md "Running the client"), with `ACDREAM_PROBE_FLAP=1`. Stand still at the Holtburg cottage vestibule/room boundary. Ask the user to confirm: (a) the grey↔texture flicker at rest is gone/reduced; (b) in the log, `[flap-sweep] desiredBack` holds a constant value (no `3.11→3.07` drift). Do not start Part 2 until the user confirms. + +--- + +## Part 2 — Viewer-cell dead-zone (belt-and-suspenders for the flicker) + +**Root cause (decomp-confirmed):** `BSPQuery.PointInsideCellBsp` (`BSPQuery.cs:1034`) uses a hard split — `dist >= 0f` → inside, `dist < 0f` → outside — with **no dead-zone**. A point grazing a splitting plane flips inside/outside on a sub-millimetre wobble. Retail's `BSPNODE::point_inside_cell_bsp` (`0x0053c1f0`, pc:325513/325522) uses a **symmetric ±0.000199999995 m** band: a point within ±0.2 mm of a plane is in *neither* child → the traversal short-circuits and classifies it "inside this cell," so a grazing point stays in the cell it was last in (`check_cell` is null → `curr_cell` unchanged, validate_transition pc:272608). This makes the viewer/player cell sticky at boundaries. + +**Faithful 3-way classify** (matches retail's `eax = 0 / 1 / 2`): +- `dist >= +ε` → clearly in front → descend `PosNode` (may still reject on a deeper plane). +- `-ε < dist < +ε` → **dead zone** → short-circuit `true` (inside this cell). +- `dist <= -ε` → clearly behind → `false` (outside). + +**Shared-primitive risk:** `PointInsideCellBsp` is also used by physics cell membership (`CellTransit.FindVisibleChildCell/FindCellList/FindCellSet`). This is retail-faithful (retail's `point_inside_cell_bsp` has the dead-zone for ALL callers), but the **full Core suite must stay at baseline** (1326/4/1). The change only affects the `(-ε, 0)` band (0–0.2 mm behind a plane flips outside→inside) and the `[0, +ε)` band (short-circuits true instead of testing deeper) — both ≤0.2 mm, retail-exact. + +**Acceptance:** +- A point 0.1 mm behind a single splitting plane returns `true` (was `false`); 1 mm behind still `false`; existing `SphereIntersectsCellBspTests.PointInsideCellBsp_PointJustOutside…` (x = −0.3 m) stays `false`. +- A `FindVisibleChildCell` graze keeps the start cell. +- Full Core suite at baseline; App suite at 187. +- **VISUAL GATE 2:** the flicker is fully gone even with deliberate slow boom motion across the boundary (no residual flip). + +### Task 2.1: RED — dead-zone unit tests + +**Files:** +- Create: `tests/AcDream.Core.Tests/Physics/PointInsideCellBspDeadZoneTests.cs` + +- [ ] **Step 1: Write the failing test file.** + +```csharp +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Tests for the retail dead-zone in +/// (port of BSPNODE::point_inside_cell_bsp, acclient_2013_pseudo_c.txt:325508 / +/// 0x0053c1f0). A point within ±0.000199999995 m of a splitting plane is in +/// NEITHER child → classified "inside this cell" (short-circuit true). This is +/// what keeps the viewer/player cell sticky at a boundary graze (the flicker fix). +/// +public class PointInsideCellBspDeadZoneTests +{ + // One splitting plane at x = 0, normal +X → the "inside" half-space is x ≥ 0. + private static CellBSPNode SinglePlaneTree() + { + var leaf = new CellBSPNode { Type = BSPNodeType.Leaf }; + return new CellBSPNode + { + SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f), + PosNode = leaf, + }; + } + + [Fact] + public void PointJustBehindPlane_WithinDeadZone_ReturnsTrue() + { + // 0.1 mm behind the plane → inside the ±0.2 mm dead zone → inside this cell. + // Pre-fix this returned FALSE (hard dist < 0 → outside) → the flicker. + var root = SinglePlaneTree(); + Assert.True(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.0001f, 0f, 0f))); + } + + [Fact] + public void PointOnPlane_ReturnsTrue() + { + var root = SinglePlaneTree(); + Assert.True(BSPQuery.PointInsideCellBsp(root, new Vector3(0f, 0f, 0f))); + } + + [Fact] + public void PointJustInFront_ReturnsTrue() + { + var root = SinglePlaneTree(); + Assert.True(BSPQuery.PointInsideCellBsp(root, new Vector3(0.0001f, 0f, 0f))); + } + + [Fact] + public void PointClearlyBehind_BeyondDeadZone_ReturnsFalse() + { + // 1 mm behind → outside the ±0.2 mm band → outside the cell (unchanged). + var root = SinglePlaneTree(); + Assert.False(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.001f, 0f, 0f))); + } + + [Fact] + public void PointFarBehind_ReturnsFalse_RegressionGuard() + { + // The existing SphereIntersectsCellBspTests regression pin (x = -0.3 m) must + // stay FALSE — the dead zone is only ±0.2 mm, 300 mm is far outside. + var root = SinglePlaneTree(); + Assert.False(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.3f, 0f, 0f))); + } +} +``` + +- [ ] **Step 2: Run to verify it fails.** + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PointInsideCellBspDeadZone"` +Expected: **FAIL** — `PointJustBehindPlane_WithinDeadZone_ReturnsTrue` fails (returns false). The other 4 pass already (they pin unchanged behaviour). + +### Task 2.2: GREEN — add the dead-zone to `PointInsideCellBsp` + +**Files:** +- Modify: `src/AcDream.Core/Physics/BSPQuery.cs` + +- [ ] **Step 1: Replace the split test.** In `PointInsideCellBsp` (`BSPQuery.cs:1034-1047`), replace the body after the leaf checks: + +```csharp + float dist = Vector3.Dot(node.SplittingPlane.Normal, point) + node.SplittingPlane.D; + + // Front or on-plane → follow positive child (inside). + if (dist >= 0f) + return node.PosNode is not null ? PointInsideCellBsp(node.PosNode, point) : true; + + // Behind → outside. + return false; +``` + +with: + +```csharp + float dist = Vector3.Dot(node.SplittingPlane.Normal, point) + node.SplittingPlane.D; + + // Retail BSPNODE::point_inside_cell_bsp dead-zone (0x0053c1f0, pc:325513/325522): + // a symmetric ±0.000199999995 m band around the splitting plane belongs to NEITHER + // child. A point in the band short-circuits "inside this cell" (true) — this is what + // keeps the viewer/player cell sticky at a boundary graze (no sub-mm membership flip + // → no indoor flicker). Only a point clearly BEHIND the plane is outside. + const float CellBspPlaneEpsilon = 0.000199999995f; + + if (dist >= CellBspPlaneEpsilon) + return node.PosNode is not null ? PointInsideCellBsp(node.PosNode, point) : true; + if (dist <= -CellBspPlaneEpsilon) + return false; // clearly behind → outside the cell + return true; // dead zone (within ±0.2 mm) → inside this cell +``` + +- [ ] **Step 2: Run the dead-zone tests to verify pass.** + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PointInsideCellBspDeadZone"` +Expected: **PASS** (5 tests). + +### Task 2.3: Stickiness regression at the `FindVisibleChildCell` level + +**Files:** +- Test: `tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs` + +- [ ] **Step 1: Inspect the existing fixtures.** Read `tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs` to reuse its cell-cache/`CellPhysics` builder. Add one test: a point that grazes the start cell's boundary plane (within ±0.2 mm, on the outside) resolves back to the **start cell** (not a neighbour). If the existing fixtures don't expose a single-plane start cell conveniently, assert the primitive instead via `BSPQuery.PointInsideCellBsp` on the start cell's `CellBSP.Root` (the dead-zone test in Task 2.1 already covers the primitive; this task is satisfied if no cheap `FindVisibleChildCell`-level fixture exists — note that in the commit message rather than forcing a brittle fixture). + +- [ ] **Step 2: Run the CellTransit suite.** + +Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransit"` +Expected: **PASS** (no regression). + +### Task 2.4: Full build + test + commit Part 2 + +- [ ] **Step 1: Build.** Run: `dotnet build`. Expected: green. +- [ ] **Step 2: FULL Core suite (shared-primitive gate).** Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj`. Expected: **1331 pass / 4 fail / 1 skip** (1326 + 5 new dead-zone tests; the 4 documented failures unchanged — verify the failing set is the SAME 4, not a new one). +- [ ] **Step 3: Commit.** + +```bash +git add src/AcDream.Core/Physics/BSPQuery.cs tests/AcDream.Core.Tests/Physics/PointInsideCellBspDeadZoneTests.cs +git commit -m "fix(physics): Part 2 — point_inside_cell_bsp dead-zone (sticky cell membership at boundary graze) + +Port retail BSPNODE::point_inside_cell_bsp's symmetric ±0.000199999995 m +dead-zone (0x0053c1f0, pc:325513/325522): a point within 0.2 mm of a splitting +plane is in neither child -> short-circuit 'inside this cell'. Belt-and-suspenders +for the indoor flicker: the viewer cell stays sticky when the boom grazes the +vestibule/room portal plane instead of flipping 0170<->0171 on a sub-mm wobble. +Shared with physics cell membership (retail-faithful: retail uses the same band +for all callers). Full Core suite at baseline. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +- [ ] **Step 4: VISUAL GATE 2 — STOP.** Launch with `ACDREAM_PROBE_FLAP=1` + `ACDREAM_PROBE_CELL=1`. Walk the boom slowly across the vestibule/room boundary. Ask the user to confirm the flicker is fully gone and `[cell-transit]` shows clean single crossings (no oscillation). Do not start Part 3 until confirmed. + +--- + +## Part 3 — w-space portal clip robustness + reassess `9f95252` (gated on the void state) + +**Status after Parts 1+2:** the eye now rests stable in the *substantial* cell (room/outside), not lingering in the thin vestibule. The `proj=0` "stable bluish void" was a degenerate projection that occurs only when the eye stands IN a portal plane looking along it — a position Parts 1+2 keep the eye out of. So Part 3 is **diagnose-then-decide**, not a fixed code change. + +**Key correction vs the handoff:** do **NOT** lower `PortalProjection.MinW` (0.05) to exactly `w = 0`. acdream's `ProjectToNdc` computes a 2D screen *region* (not a homogeneous rasterisation), so a vertex AT `w = 0` divides to `inf`/`NaN`. Retail's `polyClipFinish` produces w=0 synthetic verts because its downstream is a homogeneous rasteriser; acdream's region-clip needs `w > 0` strictly. The existing `MinW = 0.05` clip-space Sutherland–Hodgman (commit `5f596f2`) is the correct adaptation and is **kept**. The remaining faithful pieces are the InitCell side-test dead-band and removing the band-aid. + +**Acceptance:** +- After Parts 1+2's visual gates, capture `ACDREAM_PROBE_FLAP` at the cottage boundary + cellar; confirm whether any `proj=0` / `terrain=Skip` void remains. +- If clean: **revert `9f95252`** (the `EyeInsidePortalOpening` flood) and re-verify the void stays gone — this is the goal (the boom + dead-zone made it redundant). +- For faithfulness, tighten `CameraOnInteriorSide`'s `PortalSideEpsilon` toward retail's InitCell band only if it does not re-introduce culling (test-gated). +- **VISUAL GATE 3:** no stable bluish void anywhere at the cottage (boundary, cellar, exiting); the cellar still seals; no new flap. + +### Task 3.1: Diagnose the residual void (no code change) + +- [ ] **Step 1.** Launch with `ACDREAM_PROBE_FLAP=1`. At the cottage doorway and in the cellar, capture `[flap]`/`[flap-sweep]` lines. Identify any portal still showing `proj=0` while its neighbour should be visible. Record the eye position + cell relative to that portal. +- [ ] **Step 2.** Decide: + - **(A) No residual void** → go to Task 3.2 (revert the band-aid). + - **(B) Residual void at a close portal** → the eye is still reaching a degenerate position; first re-check Parts 1+2 at that spot (boom flat? cell sticky?). Only if the eye is legitimately close to a portal it must see through, port the InitCell side-test dead-band (Task 3.3) before reverting the band-aid. + +### Task 3.2: Reassess / revert the `9f95252` eye-in-portal flood band-aid + +**Files:** +- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` + +- [ ] **Step 1: Write a guard test first.** In `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` (or the nearest existing builder test file — locate with a glob), add/keep a test that a normal interior doorway floods its neighbour through the projected+clipped region WITHOUT relying on `EyeInsidePortalOpening` (i.e. with the eye a normal distance back). Run it to confirm it passes BEFORE removing the band-aid. +- [ ] **Step 2: Remove the band-aid.** Delete the `clippedRegion.Count == 0` → `EyeInsidePortalOpening` flood block (`PortalVisibilityBuilder.cs:171-177`) and replace with the plain cull: + +```csharp + if (clippedRegion.Count == 0) + continue; // portal not visible through this chain +``` + +Then delete the now-unused `EyeInsidePortalOpening`, `PointInPoly2D`, and `EyeStandingPerpDist` members (`PortalVisibilityBuilder.cs:415-474`) — confirm no other references with a grep before deleting. + +- [ ] **Step 3: Build + App suite.** Run: `dotnet build` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj`. Expected: green, no regression. + +- [ ] **Step 4: VISUAL CHECK (mini-gate).** Launch; confirm the cellar **ceiling** (the thing `9f95252` originally fixed) is still sealed with the band-aid removed (Parts 1+2 should now keep it sealed via the stable viewer cell). If the ceiling drops, the band-aid was load-bearing → **restore it** (`git revert` the removal) and record that the boom/dead-zone did not fully subsume it; keep it and move on. Either outcome is a valid, documented result. + +### Task 3.3 (conditional): Port the InitCell side-test dead-band + +Only if Task 3.1 chose path (B). Retail `PView::InitCell` (`0x005a4b70`, pc:432896-432936) treats a viewer within **±0.000199999995 m** of a portal plane as the front (positive) side. acdream's `CameraOnInteriorSide` (`PortalVisibilityBuilder.cs:326-333`) uses `PortalSideEpsilon = 0.01f`. + +- [ ] **Step 1.** Add a test in the builder test file: a camera exactly on a portal plane is treated as interior-side (traverses). A camera 1 cm clearly behind is culled. +- [ ] **Step 2.** Only change `PortalSideEpsilon` if the test + visual gate confirm it does not re-introduce a flap (tightening can cull a portal the eye is slightly behind). If it regresses, leave `0.01f` and note the divergence. Retail-faithfulness here is secondary to not re-opening the flap. + +### Task 3.4: Commit Part 3 + VISUAL GATE 3 + +- [ ] **Step 1: Commit** whatever Part 3 landed (band-aid removed, or kept-and-documented, ± side-band): + +```bash +git add -A +git commit -m "fix(render): Part 3 — reassess eye-in-portal flood after boom+dead-zone stabilise the viewer cell + +. The stable bluish void is gone +because Parts 1+2 keep the eye out of the degenerate in-portal-plane position; +MinW=0.05 clip-space Sutherland-Hodgman (5f596f2) is kept (region-clip needs w>0). + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +- [ ] **Step 2: VISUAL GATE 3 — STOP.** Full cottage tour (outside → doorway → room → cellar → back). Ask the user to confirm: no stable bluish void, cellar seals, no flicker, no new flap. This closes the flicker/void fix. + +--- + +## Post-completion + +- [ ] Update `docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md` status (or write a short ship note) and the `reference_render_pipeline_state.md` memory: flicker + void fixed via the 3-part port; note which of Parts 2/3 (band-aid) ended up load-bearing. +- [ ] Update `docs/plans/2026-05-12-milestones.md` M1.5 narrative if the indoor world now "feels right." +- [ ] If a residual remains (e.g. #78 outdoor terrain gating), file/refresh the issue — do not fold it into this fix. + +--- + +## Self-Review + +**Spec coverage (handoff §4):** +- Part 1 (camera boom stability) → Tasks 1.1–1.4. ✔ Snap ported; firewall already present (verified, noted). +- Part 2 (viewer-cell dead-zone) → Tasks 2.1–2.4. ✔ ±0.000199999995 ported into the shared point-in-cell test. +- Part 3 (w-space clip + reassess `9f95252`) → Tasks 3.1–3.4. ✔ Diagnose-then-decide; band-aid reassessment explicit; MinW correction documented. +- Per-part acceptance + visual gates → every part ends in a VISUAL GATE. ✔ +- Order Part 1 → gate → Part 2 → gate → Part 3 → gate. ✔ + +**Placeholder scan:** Part 1 and Part 2 have complete code. Part 3 is intentionally diagnostic (its concrete change depends on the Part 1+2 visual outcome) — the conditional branches and the exact files/lines are specified, and the "no MinW→0" correction is concrete. This is honest given the gating, not a placeholder. + +**Type consistency:** `ApplyConvergenceSnap(Vector3, Vector3, Vector3, Vector3) → (Vector3, Vector3, bool)` used identically in test and wiring. `SnapEpsilon`/`RotCloseEpsilon` consts referenced in helper + comment. `CellBspPlaneEpsilon` local to `PointInsideCellBsp`. `CellBSPNode { Type=…, SplittingPlane=new Plane(Vector3,float), PosNode=… }` matches `SphereIntersectsCellBspTests`. `CameraDiagnostics` statics saved/restored (they leak between tests) as the existing tests do. + +**Risk notes:** Part 2 changes a Core primitive shared with physics — the FULL Core suite is the gate, and the change is bounded to a ±0.2 mm band (retail-exact). Part 1's integration test is float-ULP-sensitive; frame count (120) + displacement (~7 m) were chosen so the un-snapped step (~5e-5 m) is tens of ULP above zero — a real RED — while still below SnapEpsilon (snapped GREEN). diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 614935be..82abcfcb 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -77,6 +77,16 @@ public sealed class RetailChaseCamera : ICamera public const float PitchMin = -0.7f; public const float PitchMax = 1.4f; + // Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp + // acclient_2013_pseudo_c.txt, 0x00456fcd–0x00457035). SnapEpsilon = 2 × + // 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail + // freezes the boom at an exact fixed point (0x00456fe1). RotCloseEpsilon = + // 0.000199999995 — the Frame::close_rotation tolerance (0x00456fdd). Without the + // snap, Vector3.Lerp asymptotes forever and the boom drifts at rest, walking the eye + // across a portal plane and flipping the viewer cell → the indoor flicker. + private const float SnapEpsilon = 0.000199999995f * 2f; + private const float RotCloseEpsilon = 0.000199999995f; + // ── Damped state ──────────────────────────────────────────────── private readonly Vector3[] _velocityRing = new Vector3[5]; @@ -146,8 +156,14 @@ public sealed class RetailChaseCamera : ICamera { float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt); float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt); - _dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha); - _dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha)); + Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha); + Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha)); + + // Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed + // point once the lerp step is sub-epsilon, instead of dithering forever. This is + // the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon. + (_dampedEye, _dampedForward, _) = + ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward); } // 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer @@ -369,6 +385,26 @@ public sealed class RetailChaseCamera : ICamera return a; } + /// + /// Retail CameraManager::UpdateCamera convergence snap (decomp 0x00456fcd). + /// After the per-frame lerp, if the translation step from + /// to is below AND the + /// rotation step is below , retail returns the input + /// position unchanged — an exact fixed point. Returns frozen=true with the + /// current state in that case; otherwise frozen=false with the candidate. + /// Both conditions are required (retail couples origin + rotation in the snap test), + /// so the boom keeps converging while the heading is still turning. + /// + internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap( + Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward) + { + bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon; + bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon; + if (translationConverged && rotationConverged) + return (dampedEye, dampedForward, true); // freeze: exact fixed point + return (candidateEye, candidateForward, false); + } + /// /// Low-pass filter for a single mouse axis. Mirrors retail's /// CameraSet::FilterMouseInput: if last sample was within diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index 6e7f8c92..d4e60347 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -605,4 +605,102 @@ public class RetailChaseCameraTests Assert.True(cam.Position.X < -2f, $"published eye should fully recover to the target after release, got {cam.Position}"); } + + // ── Convergence snap (Part 1: kills the at-rest boom drift) ──────── + + [Fact] + public void ConvergenceSnap_StepBelowEpsilon_FreezesAtCurrent() + { + // Both the translation step and the rotation step are below the retail snap + // thresholds (0.0004 m / 0.0002) → freeze: return the CURRENT damped state, + // not the candidate. This is the exact fixed point retail's UpdateCamera reaches. + var damped = new Vector3(5f, 6f, 7f); + var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f)); + var candidate = damped + new Vector3(0.0001f, 0f, 0f); // 0.1 mm step < 0.4 mm + var candFwd = forward; // no rotation step + + var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd); + + Assert.True(frozen); + Assert.Equal(damped, eye); // exact — returns the input, freezing the drift + Assert.Equal(forward, fwd); + } + + [Fact] + public void ConvergenceSnap_TranslationStepAboveEpsilon_ReturnsCandidate() + { + var damped = new Vector3(5f, 6f, 7f); + var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f)); + var candidate = damped + new Vector3(0.01f, 0f, 0f); // 1 cm step ≫ 0.4 mm + var candFwd = forward; + + var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd); + + Assert.False(frozen); + Assert.Equal(candidate, eye); // still converging → apply the lerp step + Assert.Equal(candFwd, fwd); + } + + [Fact] + public void ConvergenceSnap_RotationStepAboveEpsilon_ReturnsCandidate() + { + // Translation has converged but the heading is still turning — retail does NOT + // freeze unless BOTH are close (it returns the interpolated frame). So a small + // translation step must NOT freeze while the forward is still rotating. + var damped = new Vector3(5f, 6f, 7f); + var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f)); + var candidate = damped + new Vector3(0.0001f, 0f, 0f); // sub-epsilon translation + var candFwd = Vector3.Normalize(new Vector3(1f, 0.05f, 0f)); // ~0.05 rad turn ≫ 0.0002 + + var (_, _, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd); + + Assert.False(frozen); + } + + [Fact] + public void Update_AtRestAfterConvergence_BoomFreezesAtExactFixedPoint() + { + // The retail UpdateCamera snap freezes the boom at an exact fixed point once the + // per-frame step falls below ~0.4 mm. Without it, Vector3.Lerp asymptotes forever + // — the eye dithers sub-millimetre every frame and walks across the portal plane, + // flipping the viewer cell (the indoor flicker). Hold a pose DIFFERENT from the + // init pose so the boom has to converge over many frames; with collision OFF + // (Position == _dampedEye), two consecutive post-convergence frames must be + // BIT-IDENTICAL. (At frame 120, α≈0.075, displacement ~7 m, the un-snapped step is + // ~5e-5 m ≈ tens of float ULP — distinguishably nonzero — so this is a real RED.) + bool savedAlign = CameraDiagnostics.AlignToSlope; + bool savedColl = CameraDiagnostics.CollideCamera; + float savedT = CameraDiagnostics.TranslationStiffness; + float savedR = CameraDiagnostics.RotationStiffness; + try + { + CameraDiagnostics.AlignToSlope = false; // deterministic heading + CameraDiagnostics.CollideCamera = false; // Position == _dampedEye + CameraDiagnostics.TranslationStiffness = 0.45f; + CameraDiagnostics.RotationStiffness = 0.45f; + + var cam = new RetailChaseCamera { Distance = 2.61f, Pitch = 0.291f }; + + // Frame 1 at pose A: init snaps the damped eye to A's target. + cam.Update(Vector3.Zero, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f); + + // Hold pose B for many frames → the boom lerps A's target → B's target. + var posB = new Vector3(5f, 5f, 0f); + for (int i = 0; i < 120; i++) + cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f); + + Vector3 a = cam.Position; + cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f); + Vector3 b = cam.Position; + + Assert.Equal(a, b); // exact — frozen, not dithering + } + finally + { + CameraDiagnostics.AlignToSlope = savedAlign; + CameraDiagnostics.CollideCamera = savedColl; + CameraDiagnostics.TranslationStiffness = savedT; + CameraDiagnostics.RotationStiffness = savedR; + } + } } From 2b7f5a16c608a448c2ff6608eba8a08208a772ce Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 17:02:09 +0200 Subject: [PATCH 021/172] fix(render): branch inside/outside on is_player_outside, not the camera cell (PARTIAL) Retail SmartBox::RenderNormalMode (0x453aa0:92665) branches DrawInside vs the outdoor LScape::draw on is_player_outside (the PLAYER's cell, 0x451e80), then roots DrawInside at the VIEWER cell. acdream keyed the whole branch off the camera cell (clipRoot = visibility.CameraCell), so a 3rd-person chase camera lagging in a doorway AFTER the player stepped outside took the DrawInside path rooted at the threshold cell, where the exit-portal flood degenerates: grey world + entities-through-walls. Now ShouldRenderIndoor(playerCellId, viewerCellResolved) gates the branch on the player; the DrawInside root stays the viewer cell (handoff invariant preserved). SCOPE / HONESTY: this REDUCES the player-OUTSIDE doorway grey (visual-confirmed reduced a lot) but does NOT fix the deeper symptom: when the player is in one interior cell (cellar 0174) and the camera is in another (room 0171), the flood roots at the camera cell and does NOT seal the player's cell, so the cellar floor / interior walls drop to grey. That is the KNOWN R1-completion problem (2026-06-05 Residual A handoff + 2026-06-02 design doc section 3: a SHELL-SEALING / wrong-flood-root bug), not this branch. Tests: Core 1331p / 4f (documented) / 1s, App 187p, build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 14 +++++- .../Rendering/RenderingDiagnostics.cs | 25 +++++++++++ .../Rendering/RenderingDiagnosticsTests.cs | 43 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5c5e8932..93f1e58f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7319,7 +7319,19 @@ public sealed class GameWindow : IDisposable // (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its // binding=2 defensively from the ids we hand it. _clipFrame ??= ClipFrame.NoClip(); - var clipRoot = visibility?.CameraCell; + // Retail RenderNormalMode (0x453aa0:92665) branches inside/outside on is_player_outside + // — the PLAYER's cell (0x451e80), NOT the camera cell — then roots DrawInside at the + // VIEWER cell (this->viewer_cell) when inside. The 3rd-person chase camera LAGS the + // player, so keying the branch off the camera (the old `visibility?.CameraCell`) made + // the camera lingering in a doorway AFTER the player had stepped outside take the + // DrawInside path rooted at the threshold cell, where the exit-portal flood degenerates + // → terrain Skipped + sparse shells → grey world with only entities showing through. + // Branch on the player; keep the viewer cell as the indoor root (handoff invariant). + uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; + var clipRoot = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( + playerCellId, visibility?.CameraCell is not null) + ? visibility!.CameraCell + : null; ClipFrameAssembly? clipAssembly = null; PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 408b4842..e44a920f 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -272,4 +272,29 @@ public static class RenderingDiagnostics /// in the 8×8 landblock grid (0x0001–0x0040). /// public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; + + /// + /// The top-level render branch: should this frame run the indoor (DrawInside) path? + /// + /// Retail SmartBox::RenderNormalMode (0x453aa0, pc:92665) branches + /// DrawInside vs the outdoor LScape::draw on is_player_outside — the + /// PLAYER's cell ((player->m_position.objcell_id & 0xFFFF) < 0x100, + /// SmartBox::is_player_outside 0x451e80) — NOT the camera/viewer cell. When the + /// player is inside it then roots the flood at the viewer cell + /// (this->viewer_cell). So the inside/outside decision follows the player; + /// only the indoor root follows the camera. + /// + /// acdream historically branched on the camera cell (a non-null + /// visibility.CameraCell). A 3rd-person chase camera lags the player, so when the + /// player had already stepped outside but the camera still sat in the doorway, the camera + /// branch wrongly chose DrawInside rooted at the doorway cell, where the exit-portal flood + /// degenerates → the whole static world (terrain + shells) gated off → grey screen with + /// only entities (which bypass the gate) showing through. Branching on the player removes it. + /// + /// The player's current cell id (0 if unresolved → outside). + /// Whether a viewer/camera cell is available to root + /// DrawInside at. Indoor render needs both: the player inside AND a cell to root at. + /// + public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved) + => viewerCellResolved && IsEnvCellId(playerCellId); } diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index f490b366..9fd9232f 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -89,4 +89,47 @@ public sealed class RenderingDiagnosticsTests { Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id)); } + + // ── Render inside/outside branch (retail RenderNormalMode is_player_outside) ── + // The top-level render branch decides DrawInside vs DrawOutside. Retail + // (SmartBox::RenderNormalMode 0x453aa0:92665) keys it on is_player_outside (the + // PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the + // camera cell, so a chase camera lagging in a doorway while the player was already + // outside took the DrawInside path and degenerated to a grey world + entities showing + // through walls. These pin the player-keyed branch (DrawInside root stays the viewer cell). + + [Fact] + public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse() + { + // THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the + // chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor. + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: true)); + } + + [Fact] + public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue() + { + Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: true)); + } + + [Fact] + public void ShouldRenderIndoor_PlayerInside_NoViewerCell_ReturnsFalse() + { + // Opposite lag (camera pulled outside while the player is inside): no viewer cell to + // root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior. + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: false)); + } + + [Fact] + public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse() + { + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: false)); + } + + [Fact] + public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse() + { + // playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render). + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, viewerCellResolved: true)); + } } From 8116d101bc8b3c8cb1710f4681512b78c00ed686 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 5 Jun 2026 17:04:52 +0200 Subject: [PATCH 022/172] docs: SHELL-SEALING / wrong-flood-root handoff (cellar floor + interior walls grey) The user's primary symptom (interior walls + cellar floor render as grey clear-color with dynamic objects / outdoor slices showing through; flicker at room/cellar transitions) is the KNOWN R1-completion problem: the PView flood roots at the CAMERA cell (viewer cell), and when the camera is in a different interior cell than the player (room 0171 vs cellar 0174), the flood does not seal the player's cell. Decisive evidence: flap-cam root=0xA9B40171 playerCell=0xA9B40174. This handoff separates the two problems I conflated, lists the disproven causes, gives the next diagnostic step (shell/flap/vis probes in the cellar), and a kickoff prompt. HEAD 2b7f5a1. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-05-shell-sealing-cellar-floor-handoff.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md diff --git a/docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md b/docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md new file mode 100644 index 00000000..fd598b56 --- /dev/null +++ b/docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md @@ -0,0 +1,239 @@ +# Handoff — Indoor SHELL-SEALING / wrong-flood-root (cellar floor + interior walls drop to grey) — 2026-06-05 (PM/eve) + +> **For the next session/model. Read this FIRST.** The previous model (me) spent this session on the +> "viewer-cell flicker" 3-part plan, shipped two real-but-partial fixes, and **conflated two distinct +> problems**. The user's PRIMARY pain — **interior walls + cellar floor render as grey background with +> dynamic objects / outdoor slices showing through, plus flicker when changing rooms / entering the +> cellar** — is the **KNOWN, already-documented "R1-completion" problem**, NOT the boom and NOT the +> branch I touched. Get a SCREENSHOT + `[shell]` evidence EARLY; do not re-litigate the disproven causes +> below. +> +> **Tree:** branch `claude/thirsty-goldberg-51bb9b`, worktree +> `C:/Users/erikn/source/repos/acdream/.claude/worktrees/thirsty-goldberg-51bb9b`. HEAD `2b7f5a1`. +> Do NOT branch/worktree. Do NOT push without asking. NEVER `git stash`/`gc`. PowerShell on Windows; +> launch logs are UTF-16 (use `Select-String`/`Get-Content`, they handle it). + +--- + +## 0. The honest TL;DR + +- The user reports (with a screenshot): standing **inside** the cottage, the **floor draws but the walls + are grey** (the time-of-day clear color), with NPCs/doors/chests and a slice of the **outdoor world** + floating in the grey. In the **cellar**, the **floor is missing** (just grey). It **flickers** when + changing rooms / entering the cellar. This persisted through both of this session's fixes. +- **Decisive evidence (live, this session):** + ``` + [flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174 eyeInRoot=Y + eye=(153.46,6.66,94.92) player=(153.58,8.88,92.76) terrain=Planes outVisible=True + [flap] root=0xA9B40171 ... | p0->0170 proj=4 | p1->0173 proj=0 | p2->0175 proj=5 || outPolys=1 vis=4 + ``` + The render **roots the visibility flood at the CAMERA's cell `0171`** (the room, z≈95), but the + **PLAYER is in `0174`** (the cellar, z≈93). The flood reaches `vis=4` cells **from the room**; the + player's actual cell `0174` is **not sealed** by it (the stair-chain portal `p1→0173` is `proj=0`). + So the cellar shell around the player isn't drawn → grey. +- **This is exactly what the 2026-06-05 Residual-A handoff already flagged as NEXT:** *"when the player + is in the cellar but the eye is up in the room, clipRoot = room → the PVS flood from the room does NOT + reach the cellar → the cellar floor drops."* And the 2026-06-02 design doc §3 calls the grey a + **SHELL-SEALING bug** ("the closed cell mesh is not covering those pixels"). **The answer was in the + docs the whole time.** +- **Two fixes shipped this session (both real, both PARTIAL, neither is the cellar/walls fix):** + - `d2212cf` — Part 1 camera boom convergence snap (retail `UpdateCamera` 0x00456fcd). Freezes the + at-rest boom drift. Test-covered. The user confirmed the at-rest room was **never** flickering, so + this fixed a real-but-invisible thing. KEEP (faithful) or reassess. + - `2b7f5a1` — branch inside/outside on `is_player_outside` (retail `RenderNormalMode` 0x453aa0) instead + of the camera cell. **Reduced** the player-OUTSIDE doorway grey ("reduced a lot" — user). Test-covered. + Does NOT touch the player-inside case. KEEP (no regression — see §4) or reassess. + +--- + +## 1. The two problems (do NOT conflate them — I did, and it cost the session) + +| | Problem A — doorway transition grey | Problem B — interior shell not sealed (THE user's pain) | +|---|---|---| +| **When** | Player **fully outside** (landcell `<0x100`), camera lags **inside** the doorway | Player **inside** cell X, camera **inside** a DIFFERENT cell Y (camera in room, player in cellar) | +| **Why** | Branch keyed on camera → wrongly ran `DrawInside` rooted at the threshold | Flood roots at the camera's cell Y; the player's cell X isn't in/sealed by the flood from Y | +| **Symptom** | Whole screen grey at the in↔out threshold | Walls/cellar-floor grey while standing inside; flicker at room/cellar transitions | +| **Status** | **Reduced** by `2b7f5a1` (retail-verified) | **UNSOLVED** — this is the real target | +| **Evidence** | `outPolys=0` while the exit portal projects full-screen (`proj=6 clip=8`) | `root=0171` vs `playerCell=0174`; `vis=4` from the room excludes the cellar | + +**Problem B is the one to fix.** Problem A's fix is a genuine retail-faithful improvement that happens to +share a "grey" symptom, which is what made me conflate them. + +--- + +## 2. Problem B — what we know, and the core open question + +**The render roots the PView flood at `clipRoot = visibility.CameraCell` (the VIEWER/camera cell)** — +`GameWindow.cs:7322` (now wrapped by the `ShouldRenderIndoor` branch, but still the **viewer cell** when +indoor). Retail `RenderNormalMode` (0x453aa0:92675) literally calls `DrawInside(this->viewer_cell)`. So +rooting at the viewer cell is retail-faithful **on its face**. + +**But** when the 3rd-person camera is in a different interior cell than the player (room vs cellar), the +flood from the camera's cell does **not** seal the player's cell → the geometry **around the player** +(cellar floor, near walls) isn't drawn → grey. The decisive `[flap-cam]` line (`root=0171`, +`playerCell=0174`) is the whole story. + +**The core open question the next model MUST resolve against the decomp (don't guess — this is the +crux):** how does **retail** avoid this? Candidate mechanisms, each needs decomp verification: + +- **(a) Retail's collided camera stays in (or floods to) the player's cell.** Residual A + (`SmartBox::update_viewer` 0x00453ce0, swept `viewer_sphere`) is shipped, but verify: does retail's + `viewer_cell` actually equal the player's cell when the player is in the cellar and the boom would put + the eye up in the room? The spring arm sweeps from the **head-pivot** — if the pivot is the player's + head in the cellar, the swept eye may be stopped by the cellar ceiling and stay in `0174`. **Check + whether acdream's `viewer_cell` SHOULD be `0174` here and isn't** (i.e. the camera-collision/cell- + resolution is putting the eye in `0171` when retail would keep it in `0174`). +- **(b) Retail's flood from the viewer cell DOES reach the player's cell**, because retail's portal clip + is robust and the stair-chain portals don't go `proj=0`. Here `p1→0173 proj=0` stops the flood. Is + `0174` reachable only through `0173` (which is culled)? If so, the flood-reach is the bug, not the root. +- **(c) The design doc §5 intent: visibility roots at the PLAYER's physics cell** (`CellGraph.CurrCell`), + **eye drives projection only.** The 2026-06-05 viewer-cell handoff said *don't* switch the root to the + player cell ("retail roots `DrawInside` at viewer_cell"). **This is a real contradiction in our own + docs** — design doc §5 says player-cell-roots-visibility; the later handoff says viewer-cell-roots. + The next model should settle it from the decomp: in `RenderNormalMode`, `viewer_cell` is the argument + to `DrawInside` — but WHAT sets `viewer_cell`, and is it ever != the player's cell in normal play? If + retail's `viewer_cell` is always the player's cell (because the camera collision keeps it there), then + (a) and (c) converge and acdream's bug is that its `viewer_cell` drifts to the camera's room cell. + +**Strong hypothesis to test first (cheap):** acdream's `viewer_cell` (`visibility.CameraCell`) is wrong +here — it's the *room* because the eye is geometrically up in the room, but **retail's collided +`viewer_cell` would be the cellar** (the swept sphere from the head-pivot is stopped by the cellar +ceiling / the pivot is in the cellar). I.e. this is a **camera-cell-resolution** bug, not a flood/root +bug. Verify by reading how `visibility.CameraCell` / `viewerCellId` is computed (`CellVisibility. +FindCameraCell` + `PhysicsCameraCollisionProbe.SweepEye`) and whether the pivot/sweep should keep the +cell in `0174`. + +--- + +## 3. The next diagnostic step (do this BEFORE any fix — evidence first) + +The apparatus is committed and ready. Stand the player in the cellar with the camera up in the room +(the exact repro), and capture: + +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" +$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" +$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" +$env:ACDREAM_PROBE_FLAP="1"; $env:ACDREAM_PROBE_SHELL="1"; $env:ACDREAM_PROBE_VIS="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log +``` + +Pin these three things from the log while standing in the cellar: +1. **Is the player's cell (`0174`) in the visible set?** `[flap-cam]` shows `root=` vs `playerCell=`; + `[vis]`/`[flap]` shows `vis=` count + (turn on `ACDREAM_PROBE_VIS` for the cell-id list). If `0174` + is NOT in the set → **flood-reach / wrong-root** problem (§2 a/b/c). +2. **If `0174` IS in the set, does its shell DRAW?** `[shell]` probe (`ACDREAM_PROBE_SHELL`) reports + per-cell shell draw (geometry/texture/depth). If `0174` is visible but its shell is skipped / a + polygon is back-facing / depth-culled → **mesh-seal** problem (design doc §3: dat-dump the `0174` + EnvCell mesh, look for a missing/back-facing floor polygon). +3. **What `viewer_cell` SHOULD it be?** Compare the live `viewerCell=` to where the player is. If the + camera collision (Residual A) is failing to keep the eye's cell == the player's cell, that's §2(a). + +This single capture discriminates root-vs-flood-vs-mesh. **Don't pick a fix until it does.** + +--- + +## 4. Exactly what's committed this session (and why each is safe to keep) + +| SHA | What | Keep? | +|---|---|---| +| `d2212cf` | Part 1 boom convergence snap — `RetailChaseCamera.ApplyConvergenceSnap` + wiring; retail `UpdateCamera` 0x00456fcd (`SnapEpsilon=2×0.000199999995`, `RotCloseEpsilon=0.000199999995`). 4 new App tests. | **Keep** — retail-faithful, fixes at-rest boom drift, no regression. Not the visible fix. | +| `2b7f5a1` | Branch inside/outside on `is_player_outside` (`RenderingDiagnostics.ShouldRenderIndoor` + `GameWindow.cs:7322`). 5 new Core tests. | **Keep** — retail-faithful, reduced Problem A. **Provably no Problem-B regression:** for the player-inside case it yields `clipRoot = CameraCell`, identical to the pre-fix `visibility?.CameraCell`. | + +Plan doc (Part 1's TDD steps; Parts 2/3 there are SUPERSEDED by this handoff — +see §5): `docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md`. + +**Working tree is clean** (the TEMP w-stat probe added to `PortalVisibilityBuilder.cs` was stripped; +`git status` shows nothing under src/tests). Test baseline: **App 187p/0f**, **Core 1331p / 4f / 1s**, +build green. The 4 Core fails are the documented set (2× `DoorBugTrajectoryReplay.LiveCompare_*`, +`BSPStepUpTests.D4`, `DoorCollisionApparatusTests`). + +--- + +## 5. DO NOT RE-LITIGATE (evidence-disproven this session) + +- **The flicker is NOT viewer-cell oscillation / cell-membership instability.** Captured the render gate + (`terrain=Skip/Planes`, `outVisible`) flapping with the **viewer cell STABLE at `0171`**. The planned + **Part 2 (point_inside_cell_bsp ±0.2 mm dead-zone) was NOT implemented and is NOT the fix.** +- **The doorway grey is NOT the portal PROJECTION degenerating.** At the grey frame the exit portal + `p0→0170` projects **full-screen** (`proj=6 clip=8`, ndc spans ±1) while `outPolys=0`. So + `ProjectToNdc` is fine; the **`OutsideView`/flood assembly** (and, per §2, the **root/flood-reach**) + is the issue. Do not "harden the w-clip" (5f596f2 already did the clip-space side-plane clip; the + 9f95252 eye-in-portal flood band-aid is still in — reassess only after Problem B is understood). +- **The boom drift (Part 1) was real but is NOT the visible flicker.** Freezing the boom did not change + the user-visible symptom. +- The 3-part plan's framing (flicker = boom + cell dead-zone; void = clip) was the previous-session + hypothesis; this session's live evidence **reassigns** the dominant symptom to Problem B (shell + sealing / flood root). Treat the plan's Parts 2-3 as superseded. + +--- + +## 6. Canonical prior art (already documents Problem B — read these) + +- **`docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md`** — *"player in + cellar, eye in room → clipRoot=room → flood doesn't reach the cellar → cellar floor drops. NEXT = the + CORE inside render (R1 completion)."* THE pointer. +- **`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`** §2 (the binary + `RenderNormalMode` model), §3 (grey = SHELL-SEALING bug; `[shell]` probe + dat dump), §5 (the + two-camera invariant — and the doc/handoff contradiction to settle, §2 above). +- **`memory/reference_render_pipeline_state.md`** — Residual A made the viewer cell *accurate*, which + **exposed** that the flood doesn't reach the player's cell. (This session is more evidence for that.) +- **`memory/feedback_render_one_gate.md`** + **`memory/feedback_verify_render_seal_before_layering.md`** + — get a SCREENSHOT + `[shell]` evidence EARLY; one gate for all geometry. +- **`memory/feedback_render_downstream_of_membership.md`** — a transition flicker can be a membership/ + flood-root bug, not a render bug. + +--- + +## 7. Kickoff prompt (copy-paste) + +``` +Continue acdream M1.5 indoor render in worktree thirsty-goldberg-51bb9b (branch +claude/thirsty-goldberg-51bb9b, HEAD 2b7f5a1). Do NOT branch/worktree; do NOT push without asking; +NEVER git stash/gc. PowerShell on Windows; launch logs are UTF-16. Running the client: see CLAUDE.md; ++Acdream spawns at the Holtburg cottage. + +TARGET BUG (the user's real pain, with a screenshot): standing INSIDE the cottage, the floor draws but +the WALLS are grey (the time-of-day clear color) with NPCs/doors/outdoor-slices showing through; in the +CELLAR the FLOOR is missing (grey); flicker when changing rooms / entering the cellar. This is the KNOWN +"R1-completion" SHELL-SEALING / wrong-flood-root problem, NOT the camera boom and NOT the inside/outside +branch (both partially fixed this session). + +READ FIRST (in order): +1. docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md (THIS handoff — the two problems §1, + the decisive root=0171/playerCell=0174 evidence §2, the next diagnostic step §3, the DO-NOT-RETRY §5). +2. docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md (the R1-completion + pointer) + docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md (§2/§3/§5). +3. memory: reference_render_pipeline_state, feedback_render_one_gate, + feedback_verify_render_seal_before_layering, feedback_render_downstream_of_membership. + +DO (evidence first — this bug has burned many speculative fixes; do NOT add a workaround): +- Reproduce + capture with ACDREAM_PROBE_FLAP=1 ACDREAM_PROBE_SHELL=1 ACDREAM_PROBE_VIS=1, player in the + CELLAR with the camera up in the room. Pin: (1) is the player's cell (0174) in the visible set? (2) if + so, does its shell DRAW ([shell] probe) or is a floor polygon missing/back-facing (dat-dump the 0174 + EnvCell mesh, design doc §3)? (3) is acdream's viewer_cell wrong — should retail's collided viewer_cell + be the cellar (0174), not the room (0171)? (See §2 a/b/c — settle the design-doc-vs-handoff + viewer-cell-vs-player-cell contradiction against the decomp: RenderNormalMode 0x453aa0 / update_viewer + 0x00453ce0 / what sets viewer_cell.) +- THEN fix the actual cause (camera-cell-resolution keeping the eye in the player's cell, OR the flood + reaching the player's cell, OR the mesh seal) — retail-faithful, TDD where the logic is pure, + visual-verify with the user. + +DON'T (§5, evidence-disproven): the flicker is NOT viewer-cell oscillation (Part 2 dead-zone is NOT the +fix); the doorway grey is NOT the portal projection (it projects full-screen while OutsideView is empty); +the boom (Part 1) is not the visible bug. Get a SCREENSHOT/[shell] evidence EARLY; don't declare a fix +before the user's eyes confirm it. + +TEST BASELINE: App 187p/0f. Core 1331p / 4f (documented) / 1s. Build green. +``` + +--- + +## 8. A note from the outgoing model (honest) + +I repeatedly presented fixes with too much confidence and conflated Problem A (doorway grey, which I did +reduce) with Problem B (the cellar/walls shell-sealing, which I did not touch). The user called this out +correctly. The two committed changes are real and test-backed, but the **next model should treat the +user's screenshot symptom (walls + cellar floor grey, interior-transition flicker) as Problem B from the +start**, gather `[shell]`/`[flap]` evidence in the cellar BEFORE proposing anything, and **get the user's +eyes on a sealed result before claiming success.** From eb7b1fa67c6273c2742e864857d0327fb05754cd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 6 Jun 2026 21:28:27 +0200 Subject: [PATCH 023/172] =?UTF-8?q?docs:=20spec=20=E2=80=94=20verbatim=20r?= =?UTF-8?q?etail=20indoor=20render=20port=20(DrawInside/DrawCells)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for replacing the indoor render approximation layer with a verbatim port of retail PView::DrawCells (0x5a4840). Locates the grey/bleed in the ClipFrameAssembler slot-pool + drawableCells filter (RetailPViewRenderer.cs:52/237): visible cells without a clip-slot are dropped (grey) and the per-cell trim was globally disabled (bleed). Plan: draw EVERY OrderedVisibleCells cell, trim shells per-slice via ClipPlaneSet gl_ClipDistance, draw objects membership+depth gated (no hard clip → no half-character). Scope A+B (DrawInside + look-in DrawPortal); keeps the faithful PortalVisibilityBuilder + ProjectToClip/ClipToRegion ported this session. Local commit only (not pushed). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...rbatim-retail-indoor-render-port-design.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md diff --git a/docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md b/docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md new file mode 100644 index 00000000..743ba6da --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md @@ -0,0 +1,264 @@ +# Verbatim Retail Indoor Render Port (`DrawInside` / `DrawCells`) — Design — 2026-06-06 + +> **Why this exists.** Two weeks of patching the indoor renderer have not produced +> retail's seamless inside↔outside↔inside behavior. The interior **walls/floor render +> grey** (the clear color shows through) and geometry **bleeds** between cells. Every +> attempt kept an *approximation layer* over retail's membership logic and patched its +> symptoms. This spec stops that: it ports retail's `DrawCells` **verbatim at the +> algorithm level** and **deletes the approximation layer** that keeps reintroducing the +> bug. Scope agreed with the user: **A + B** (indoor seal + look-out, plus look-in from +> outside). The outdoor `LScape` branch is out of scope — it already works. + +> **Worktree:** `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`, +> HEAD `8116d10`. PowerShell on Windows; launch logs are UTF-16. Do NOT branch/worktree, +> push, or `git stash`/`gc`. + +--- + +## 1. The problem, located in the code + +The indoor draw lives in `RetailPViewRenderer.DrawInside` (`src/AcDream.App/Rendering/RetailPViewRenderer.cs`). +Its loop *structure* already mirrors retail (landscape → exit masks → shells → objects, +reverse `OrderedVisibleCells`, per-cell, per-slice). **Two things defeat it:** + +1. **Dropped shells → grey.** `RetailPViewRenderer.cs:52` + `drawableCells = clipAssembly.CellIdToSlot.Keys`, and every loop does + `if (!drawableCells.Contains(cellId)) continue;`. A visible cell is drawn **only if + `ClipFrameAssembler` assigned it a clip-slot.** Any cell whose view did not yield a + slot is silently skipped → its sealed shell is never drawn → the clear color shows → + **grey**. Retail draws **every** cell in `cell_draw_list`. + +2. **No trim → bleed (and the half-character).** `RetailPViewRenderer.cs:237` `UseIndoorMembershipOnlyRouting()` + sets `_envCells.SetClipRouting(null)` — the per-cell trim was **globally disabled** as + an emergency fix for "characters/shells sliced at stair/door boundaries." So cells that + *do* draw are not trimmed to the opening they're seen through → geometry bleeds; and the + reason it had to be disabled is the clip was being applied to **objects/characters** + (which retail never hard-clips), slicing them. + +Both failure modes come from the `ClipFrameAssembler` slot-pool + `drawableCells` filter +sitting on top of the (faithful) membership. **That layer is the "bad code."** + +## 2. What retail does (the oracle) + +From the named decomp (Sept 2013 EoR). `SmartBox::RenderNormalMode` (0x453aa0) branches on +`is_player_outside`: outside → `LScape::draw`; inside → `DrawInside(viewer_cell)`. + +`PView::DrawInside` (0x5a5860) seeds the root cell's view to the full screen, then runs two +phases: + +- **`ConstructView` (0x5a57b0)** — build membership. Distance-priority flood from the root; + each popped cell is **appended to `cell_draw_list`** (once); `ClipPortals` clips its + portals against its view; `AddViewToPortals` propagates the clipped openings to neighbours + and enqueues new ones. `cell_draw_list` is the **single** membership source. +- **`DrawCells` (0x5a4840)** — draw, three loops over **reverse** `cell_draw_list` (far→near): + - **Landscape:** if `outside_view.view_count > 0` → `LScape::draw` clipped to `outside_view`, + then `Clear(DEPTH)`. + - **Loop 1 — exit-portal masks:** per cell, per `portal_view` slice: `setup_view(cell, slice)`; + for each exit portal `DrawPortalPolyInternal` (depth mask for the opening). + - **Loop 2 — shells:** per cell, per slice: `setup_view(cell, slice)`; `DrawEnvCell(cell)` + — the **closed cell mesh** (walls/floor/ceiling), hard-clipped to the slice. + - **Loop 3 — objects:** per cell: `Render::PortalList = cell.portal_view[last]`; + `DrawObjCellForDummies(cell)` — the cell's objects, **visibility-gated by the portal view, + not hard-clipped** (so whole creatures are never sliced). + +The trim mechanism is `setup_view` + `polyClipFinish` (0x6b6d00): clip geometry to the +slice's **convex screen region** (CPU, every frame). The two facts that make retail seamless: +**(i)** every `cell_draw_list` cell gets its closed shell drawn (it seals); **(ii)** shells are +hard-clipped per-slice, objects are only visibility-gated. + +## 3. Goal & success criteria + +- Standing **anywhere inside** (room, cellar, on stairs), the interior is **sealed**: no grey, + no see-through walls, cellar floor + stairs present, character whole. +- **Seamless transitions:** room↔room, room↔cellar, outside→inside (walk in), and **look-in** + from outside through an open door (B). +- **Look-out:** windows/doors show the outdoor world through the opening. +- The draw is a **literal translation** of `DrawCells` (every visible cell's shell, per-slice + trim on shells only, objects visibility-gated), with the slot-pool/filter layer **deleted**. +- Acceptance is **visual** (the user's eyes) — pure logic is unit-tested, the draw is GPU. + +Non-goals: rewriting the outdoor `LScape` branch; fixing any residual texture-pipeline issue +that survives a correct seal (would be a separate, evidence-led follow-up). + +## 4. Architecture + +The pipeline is one binary branch (retail `RenderNormalMode`), already in place at +`GameWindow.cs:7343`: `ShouldRenderIndoor` → indoor `DrawInside` vs outdoor `LScape` + +look-in `DrawPortal`. This spec rewrites the **indoor draw body** and the **look-in body**; +it does not change the branch or the outdoor body. + +### 4.1 Components + +| Unit | Role | Change | +|---|---|---| +| `PortalVisibilityBuilder` → `PortalVisibilityFrame.OrderedVisibleCells` + per-cell `CellView` | retail `ConstructView` / `cell_draw_list` + `portal_view` | **KEEP** (faithful, unit-tested) | +| `PortalProjection.ProjectToClip` / `ClipToRegion` | retail `GetClip` / `polyClipFinish` (homogeneous) | **KEEP** (ported this session) | +| `ClipPlaneSet.From(CellView)` | NDC convex region → ≤8 `gl_ClipDistance` planes / scissor AABB / nothing-visible | **KEEP**, call **per slice** | +| `EnvCellRenderer.Render(pass, {cellId})` | draw one cell's closed shell | **KEEP**; drive per-cell, per-slice | +| `WbDrawDispatcher.Draw(...)` | draw entity meshes | **KEEP**; drive per-cell, **no clip** | +| `ClipFrame` | upload clip region to the shader (SSBO) + terrain clip UBO | **SIMPLIFY** to one region (the current slice) | +| `RetailPViewRenderer.DrawInside` / `DrawPortal` | the indoor / look-in orchestration | **REWRITE** to the verbatim `DrawCells` loop | +| `ClipFrameAssembler` (+ `ClipFrameAssemblerTests`) | the slot-pool that produces `CellIdToSlot` / `ClipViewSlice[]` | **DELETE** | +| `drawableCells` filter | "draw only cells with a slot" | **DELETE** (draw all `OrderedVisibleCells`) | +| `UseIndoorMembershipOnlyRouting` / clip-off compromise | globally disables the trim | **DELETE** | +| `InteriorEntityPartition` | bucket entities by cell | **KEEP** as the cell→objects map (not as an eligibility filter); call with **all** visible cells | +| `InteriorRenderer` | outdoor entity-bucket wrapper | **KEEP** (outdoor path) — re-evaluate if it becomes dead | + +### 4.2 The new `DrawCells` loop (verbatim translation) + +`RetailPViewRenderer.DrawInside(viewerCell)` becomes, in pseudocode: + +``` +frame = PortalVisibilityBuilder.Build(viewerCell, eye, lookup, viewProj) // cell_draw_list + per-cell CellView +cells = frame.OrderedVisibleCells // NO drawableCells filter +objectsByCell = InteriorEntityPartition.Partition(cells, landblockEntries).ByCell + +// --- Landscape through outside_view (look-out) --- +if frame.OutsideView not empty: + for slice in frame.OutsideView.Polygons: + setSliceClip(slice) // ClipPlaneSet.From(slice-as-CellView) + drawLandscapeSlice(slice) // GameWindow callback (terrain/sky/scenery clipped to slice) + clearDepth(outsideView bounds) + +// --- Loop 1: exit-portal depth masks (only needed with look-out) --- +for cell in reverse(cells) where cell.drawing_bsp: + for slice in cell.CellView.Polygons: + setSliceClip(slice); drawExitPortalMasks(cell) // depth-only, punches the openings + +// --- Loop 2: SHELLS (the seal) --- +for cell in reverse(cells) where cell.drawing_bsp: + for slice in cell.CellView.Polygons: + planes = ClipPlaneSet.From(singlePolygonRegion(slice)) // <=8 planes, or scissor, or nothing + if planes.IsNothingVisible: continue + applyShellClip(planes) // gl_ClipDistance (or scissor) + EnvCellRenderer.Render(Opaque, {cell}); Render(Transparent, {cell}) +clearShellClip() + +// --- Loop 3: OBJECTS (no hard clip) --- +for cell in reverse(cells): + if objectsByCell[cell] empty: continue + WbDrawDispatcher.Draw(objectsByCell[cell], frustum, animatedIds) // depth + frustum + membership; NO clip + drawCellParticles(cell) +``` + +Two differences from retail are intentional GL adaptations, both faithful in result: + +- **Trim is `gl_ClipDistance` (set per slice), not CPU `polyClipFinish`.** Same convex-region + clip; `ClipPlaneSet.From` already produces the planes. A slice that exceeds 8 edges degrades + to its scissor AABB (over-includes a sliver, never drops geometry). +- **Objects are membership-gated, not hard-clipped.** Retail visibility-tests objects against + `PortalList`; we draw the cell's objects (depth + frustum) without clip planes — this is what + prevents the half-character. (A per-object portal-view visibility test is a possible future + refinement if objects visibly poke past a doorway; the cell shells + depth occlude most cases.) + +### 4.3 Look-in (B) — `DrawPortal` + +Identical loop, seeded by `PortalVisibilityBuilder.BuildFromExterior` (exterior-facing portals) +instead of the root cell. It reuses Loops 1–3 unchanged; there is no second draw engine. Runs in +the outdoor branch after `LScape`, before scene particles, exactly where it is wired today +(`GameWindow.cs:7552`). + +### 4.4 Clip application detail + +`setSliceClip` / `applyShellClip` turn one `ClipPlaneSet` into GPU state: +- `Count > 0` → upload the ≤8 planes (one region, slot 0) and enable that many `gl_ClipDistance` + outputs; the existing mesh/terrain shaders already read a clip region and write + `gl_ClipDistance`, so the shader side is unchanged — only the *feed* shrinks from a slot pool + to one region. +- `UseScissorFallback` → `glScissor` to `ScissorNdcAabb` (mapped to pixels), no clip planes. +- `IsNothingVisible` → draw nothing for that slice. +`clearShellClip` disables all `gl_ClipDistance` + scissor so Loop 3 (objects) and downstream +passes are unclipped. + +## 5. Data flow (per indoor frame) + +``` +ShouldRenderIndoor(player) == true + → RetailPViewRenderer.DrawInside(viewerCell, eye, viewProj, callbacks) + Build → cells + per-cell CellView + OutsideView + InteriorEntityPartition → objectsByCell + look-out: per OutsideView slice → setSliceClip → DrawLandscapeSlice (GameWindow GL callback) → clearDepth + Loop1 exit masks (reverse cells, per slice) + Loop2 shells (reverse cells, per slice, clip ON) → EnvCellRenderer.Render({cell}) + Loop3 objects (reverse cells, clip OFF) → WbDrawDispatcher.Draw + DrawCellParticles +``` + +GameWindow keeps providing the GL-bound callbacks it already passes today +(`DrawLandscapeSlice`, `ClearDepthSlice`, `DrawCellParticles`, `EmitDiagnostics`); only their +*orchestration* inside `RetailPViewRenderer` changes. + +## 6. Error handling / edge cases + +- **Empty `CellView` for a visible cell** (`ClipPlaneSet.IsNothingVisible`): skip that slice's + draw, but the cell may still draw via its other slices. (A cell with *no* non-empty slice is + effectively not visible — consistent with retail, where it would not be in `cell_draw_list`.) +- **Slice > 8 edges:** scissor-AABB fallback (over-include, never drop). Expected to be rare + (a single doorway opening is ~4–6 edges). +- **Eye standing in a portal / behind-eye portal:** handled upstream by the faithful + `ProjectToClip` (eye-plane clip) + the existing `EyeInsidePortalOpening` flood gate in the + builder — unchanged by this spec. +- **No exit portal:** `OutsideView` empty → no landscape/look-out, no depth-clear; interior still + fully sealed by Loop 2. + +## 7. Testing + +- **Unit (already green, must stay):** `PortalVisibilityBuilderTests` (membership/cell list), + `PortalProjectionTests` (clip math), `ClipPlaneSet` behavior. App suite baseline 205/205. +- **New unit:** the `DrawCells` orchestration is GL-bound, so extract the pure decision — + *"which (cell, slice) pairs are drawn, in what order"* — into a testable function over a + `PortalVisibilityFrame`, and assert: (a) **every** `OrderedVisibleCells` cell with a non-empty + view appears in the shell pass (regression test for the grey: no cell is dropped); (b) reverse + (far→near) order; (c) objects pass has no clip state. This pins the two bugs from §1 as tests. +- **Integration:** visual, with light `[shell]`/`[vis]` probes confirming `draw=[…]` equals the + visible-cell set (no cell dropped) at the cottage + cellar. **Acceptance is the user's eyes** + on a sealed cottage + cellar with seamless transitions. + +## 8. Risks & mitigations + +- **Per-slice shell clip re-slices shells at boundaries** (the symptom that caused the emergency + clip-off). Mitigation: the slices now come from the *faithful* `ClipToRegion` (not the old + degenerate projection), and **only shells are clipped** (objects never are). If a shell still + gaps, that is a too-small slice — a visible, localized clip-math case to fix, **not** a return + to dropping cells. +- **Texture-pipeline grey could survive a correct seal.** HEAD's commit notes "interior walls + grey." If, after every visible cell's shell draws, walls are still untextured (vs. clear-color + grey), that is a *separate* surface/texture bug (out of scope here) — but the seal must be + correct first to even tell the two apart. +- **Per-cell draw-call count.** Indoor frames have a handful of visible cells × a few slices → + tens of draws, not thousands. Acceptable; matches retail's per-cell-per-slice cadence. + +## 9. File-level change list + +- **Rewrite:** `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (the `DrawCells` loop; + delete `drawableCells`, `UseIndoorMembershipOnlyRouting`, slot routing). +- **Delete:** `src/AcDream.App/Rendering/ClipFrameAssembler.cs` + + `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs`; the `ClipViewSlice`/slot types + if unused after the rewrite. +- **Simplify:** `src/AcDream.App/Rendering/ClipFrame.cs` (one region per slice, no slot pool); + `src/AcDream.App/Rendering/ClipPlaneSet.cs` stays as-is (already does the per-slice math); + `EnvCellRenderer` / `WbDrawDispatcher` clip-routing API trimmed to "set one region / clear". +- **Keep, re-purpose:** `InteriorEntityPartition` (cell→objects map for all visible cells). +- **Light touch:** `GameWindow.cs` indoor/look-in call sites (callbacks unchanged; remove + references to deleted types). +- **Untouched:** `PortalVisibilityBuilder`, `PortalProjection`, the outdoor `LScape` branch, + `SkyRenderer`, `TerrainModernRenderer`, `ParticleRenderer`. + +## 10. Decomp references + +- `SmartBox::RenderNormalMode` 0x453aa0 — the `is_player_outside` branch. +- `PView::DrawInside` 0x5a5860 — seed full-screen view, `ConstructView`, `DrawCells`. +- `PView::ConstructView` 0x5a57b0 — `cell_draw_list` build (= `OrderedVisibleCells`). +- `PView::DrawCells` 0x5a4840 — the three loops (this spec's §4.2). +- `CEnvCell::setup_view` / `ACRender::polyClipFinish` 0x6b6d00 — per-slice convex clip (= `ClipPlaneSet` + `gl_ClipDistance`). +- `RenderDeviceD3D::DrawObjCellForDummies` 0x5a0760 — objects gated by `PortalList`, not hard-clipped. +- `PView::GetClip` 0x5a4320 / `PrimD3DRender::xformStart` 0x59b990 — homogeneous projection (= `ProjectToClip`). + +## 11. Prior art / context + +- `docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md` — the grey = shell-sealing / + flood-root, **not** the projection; "draw every visible cell's shell." +- `docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md` — the attempt + history (the slot-pool/filter layer this spec deletes). +- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` — the `DrawCells` model. +- This session: `PortalProjection.ProjectToClip`/`ClipToRegion` (homogeneous `GetClip` port) + + `PortalVisibilityBuilder` made faithful — the membership + clip-math this draw builds on. From 2ec8f41200f5bf88ab25eb5753ea6b950099bc29 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 6 Jun 2026 21:44:19 +0200 Subject: [PATCH 024/172] =?UTF-8?q?docs:=20implementation=20plan=20+=20pic?= =?UTF-8?q?kup=20handoff=20=E2=80=94=20verbatim=20retail=20DrawCells=20por?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-by-task plan (TDD pin for the grey regression + per-task visual gates) to replace the indoor-render approximation with a verbatim PView::DrawCells port, sequenced so Task 2 alone should kill the grey. Pickup handoff for a fresh session: state, baselines, rules, do-not-relitigate. Local commit only (not pushed). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-verbatim-drawcells-port-pickup-handoff.md | 79 +++ ...6-06-verbatim-retail-indoor-render-port.md | 567 ++++++++++++++++++ 2 files changed, 646 insertions(+) create mode 100644 docs/research/2026-06-06-verbatim-drawcells-port-pickup-handoff.md create mode 100644 docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md diff --git a/docs/research/2026-06-06-verbatim-drawcells-port-pickup-handoff.md b/docs/research/2026-06-06-verbatim-drawcells-port-pickup-handoff.md new file mode 100644 index 00000000..c20fa223 --- /dev/null +++ b/docs/research/2026-06-06-verbatim-drawcells-port-pickup-handoff.md @@ -0,0 +1,79 @@ +# Pickup Handoff — Verbatim Retail Indoor Render Port (execute in a new session) — 2026-06-06 + +This session **designed and planned** the verbatim retail `DrawCells` port; the next session +**executes** it. Spec + plan are committed; render code is NOT yet changed for the port. + +## Start here (read in order) + +1. **Plan (execute this):** `docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md` +2. **Spec (the why):** `docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md` +3. **Retail model:** `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` + +## What this fixes (the user's 2-week pain) + +Interior walls/floor render **grey** (clear color shows through) and geometry **bleeds** +between cells; character cut in half on stairs; flap at transitions. Root, located in code: +`RetailPViewRenderer.cs:52` drops visible cells lacking a `ClipFrameAssembler` slot (grey), and +`:237` globally disabled the per-cell trim (bleed) because it was wrongly applied to objects +(half-character). The fix = port retail `PView::DrawCells` (0x5a4840) verbatim: draw **every** +`OrderedVisibleCells` cell's shell, trimmed **per-slice** via `ClipPlaneSet`→`gl_ClipDistance`; +objects membership+depth gated, **no** clip. Scope **A+B** (DrawInside + look-in DrawPortal). + +## Current tree state + +- Branch `claude/thirsty-goldberg-51bb9b`. **Committed this session (local, NOT pushed):** the + spec (`eb7b1fa`) and the plan + this handoff. +- **Uncommitted (dirty) — KEEP, do not revert:** this session's faithful work is the foundation + the plan builds on — + - `PortalProjection.ProjectToClip` / `ClipToRegion` = homogeneous `GetClip`/`polyClipFinish` + (NEW, tested). `PortalVisibilityBuilder` rewired to use them (merged with a concurrent + agent's `[pv-trace]` work). These are the membership + clip math; the plan does NOT touch them. + - The rest of the dirty render tree (RetailPViewRenderer approximation, ClipFrameAssembler, + GameWindow rework, ObjectMeshManager #6, etc.) is the tangle the plan rewrites/deletes. +- **Baselines (must hold at start):** `dotnet build -c Debug` 0 errors; App.Tests **205/205**; + Core.Tests **1331 pass / 4 fail / 1 skip** — the 4 fails are pre-existing Physics door/step-up + (`BSPStepUpTests.D4_Airborne…`, two `DoorBugTrajectoryReplay.LiveCompare_*`, + `DoorCollisionApparatus…DocumentsBug`), unrelated to render. + +## Rules (user-set, this worktree) + +PowerShell on Windows; launch logs UTF-16. Do **NOT** branch/worktree, push, `git stash`/`gc`, +or revert the dirty tree. Build before every launch. **Acceptance is the user's eyes** — do not +claim a GL task done on a green build; only on the user's visual confirmation (the plan gates each +GL task on a launch). Live server: ACE `127.0.0.1:9000`, account `testaccount`/`testpassword`, +char `+Acdream` (spawns at the Holtburg cottage). Graceful-close the client between launches +(hard-kill leaves the ACE session stuck ~3 min). + +## DO NOT re-litigate (evidence-disproven) + +- The grey is **shell-sealing / wrong-flood-root**, NOT the portal projection. Do not "harden the + w-clip" further — the clip math is already faithful this session. (Two handoffs contradicted on + this; the 2026-06-05 shell-sealing handoff + the live visual were right.) +- If, after every shell draws (per `[render-sig] draw=[…]`), walls are sealed but **untextured** + (grey-but-drawn, vs. clear-color grey), that is a **separate surface/texture bug** (HEAD commit + notes "interior walls grey") — file it; do not reopen membership/clip. + +## Recommended execution + +Subagent-driven (a fresh subagent per task, review between) or inline (`executing-plans`). The plan +is sequenced so **Task 2 alone should make the grey disappear** — verify that with the user before +continuing; it de-risks the whole effort. + +## Copy-paste pickup prompt + +``` +Execute the verbatim retail indoor render port in worktree thirsty-goldberg-51bb9b +(branch claude/thirsty-goldberg-51bb9b). Do NOT branch/worktree, push, git stash/gc, or +revert the dirty tree. PowerShell; launch logs UTF-16; build before launch; acceptance is the +user's eyes (gate every GL task on a launch + the user's visual OK). + +Read first: +1) docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md (execute this, task by task) +2) docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md +3) docs/research/2026-06-06-verbatim-drawcells-port-pickup-handoff.md (state + rules + do-not-relitigate) + +Confirm baselines (build 0 errors; App 205/205; Core 1331/4 pre-existing/1), then use +superpowers:executing-plans (or subagent-driven-development) to implement. Task 2 should make the +grey disappear — get the user's visual confirmation before continuing. The grey is shell-sealing, +NOT the projection; the clip math is already faithful — do not harden the w-clip. +``` diff --git a/docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md b/docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md new file mode 100644 index 00000000..e196ad0d --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md @@ -0,0 +1,567 @@ +# Verbatim Retail Indoor Render Port — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the indoor-render approximation layer with a verbatim port of retail `PView::DrawCells` so interiors seal (no grey, no bleed, no half-character) and inside↔outside↔inside is seamless. + +**Architecture:** Keep the faithful membership (`PortalVisibilityBuilder` = `cell_draw_list`) and clip math (`PortalProjection.ProjectToClip`/`ClipToRegion` = `GetClip`/`polyClipFinish`). Rewrite `RetailPViewRenderer.DrawInside`/`DrawPortal` into retail's three `DrawCells` loops: draw **every** visible cell's shell, trimmed **per-slice** with `ClipPlaneSet`→`gl_ClipDistance`; draw objects membership+depth gated with **no** clip. Delete the `ClipFrameAssembler` slot-pool + `drawableCells` filter that drop shells (grey) and the global clip-off that caused bleed. + +**Tech Stack:** C# .NET 10, Silk.NET OpenGL 4.3 (bindless + MDI), xUnit. PowerShell on Windows; launch logs UTF-16. + +**Spec:** `docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md` (commit `eb7b1fa`). Read it first. + +**Worktree:** `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`. Do NOT branch/worktree, push, or `git stash`/`gc`. Do NOT revert the dirty render tree. Build before launch. + +--- + +## Orientation for the executing session (read once) + +- **Baseline:** App.Tests 205/205; Core.Tests 1331 pass / 4 fail (pre-existing Physics door/step-up, unrelated) / 1 skip; `dotnet build -c Debug` 0 errors. If these don't hold at start, stop and report — something drifted. +- **The two bugs this plan kills** (both in `src/AcDream.App/Rendering/RetailPViewRenderer.cs`): + 1. `:52` `drawableCells = clipAssembly.CellIdToSlot.Keys` + `if (!drawableCells.Contains(cellId)) continue;` in every loop → cells without a clip-slot are dropped → **grey**. + 2. `:237` `UseIndoorMembershipOnlyRouting()` → `_envCells.SetClipRouting(null)` → trim globally off → **bleed**; it was disabled because the clip was (wrongly) applied to objects/characters → **half-character**. +- **Retail oracle** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`): `PView::DrawCells` 0x5a4840 — three loops over reverse `cell_draw_list`: exit-portal masks → shells (`DrawEnvCell`, per-slice `setup_view` clip) → objects (`DrawObjCellForDummies`, visibility-gated, NOT hard-clipped). +- **Key existing APIs you'll reuse:** + - `PortalVisibilityFrame` (in `PortalView.cs`): `OrderedVisibleCells` (List, closest-first), `CellViews` (Dictionary), `OutsideView` (CellView). `CellView.Polygons` is the slice list; `ViewPolygon.Vertices` is Vector2[] NDC. + - `ClipPlaneSet.From(CellView)` → `Count`/`Planes` (≤8), `UseScissorFallback`+`ScissorNdcAabb`, `IsNothingVisible`. + - `ClipFrame`: `Reset()`, `AppendSlot(ClipPlaneSet)`→slot index, `SetTerrainClip(planes)`, `UploadShared(gl)`, `RegionSsbo`. Slot 0 is reserved no-clip. + - `EnvCellRenderer`: `SetClipRegionSsbo(uint)`, `SetClipRouting(IReadOnlyDictionary?)` (null = no-clip), `Render(WbRenderPass, HashSet filter)`, `PrepareRenderBatches(...)`. + - `WbDrawDispatcher`: `Draw(camera, entries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds)`, `SetClipRegionSsbo`, `ClearClipRouting()`. +- **Verification reality:** the pure draw-ORDER is unit-tested (Task 1). Every GL task is verified by **launch + your eyes** + the `[render-sig]` / `[shell]` probes — there is no unit test for a GPU draw. Launch command is in the Appendix. **Do not mark a GL task done on a green build alone — only on the user's visual confirmation.** + +--- + +## File Structure + +- **Create:** `src/AcDream.App/Rendering/IndoorDrawPlan.cs` — pure (GL-free) function turning a `PortalVisibilityFrame` into the reverse-ordered shell draw list (every visible cell, no filter). Test seam for the grey regression. +- **Create:** `tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs`. +- **Rewrite:** `src/AcDream.App/Rendering/RetailPViewRenderer.cs` — the three `DrawCells` loops; remove `drawableCells`, `UseIndoorMembershipOnlyRouting`, the `ClipFrameAssembler` dependency. +- **Delete:** `src/AcDream.App/Rendering/ClipFrameAssembler.cs` + `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs` (and the `ClipViewSlice`/`ClipFrameAssembly` types if unused after the rewrite). +- **Light touch:** `src/AcDream.App/Rendering/GameWindow.cs` — the indoor/look-in call sites + `sig*` diagnostics that read `ClipAssembly.OutsideViewSlices` (switch to `pvFrame.OutsideView.Polygons.Count`). +- **Untouched:** `PortalVisibilityBuilder.cs`, `PortalProjection.cs`, `ClipPlaneSet.cs`, `ClipFrame.cs`, `EnvCellRenderer.cs`, `WbDrawDispatcher.cs`, the outdoor `LScape` branch. + +--- + +## Task 1: Pure shell-draw-order function (pins the grey regression) + +**Files:** +- Create: `src/AcDream.App/Rendering/IndoorDrawPlan.cs` +- Test: `tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class IndoorDrawPlanTests +{ + private static ViewPolygon Quad() => new(new[] + { new Vector2(-1,-1), new Vector2(1,-1), new Vector2(1,1), new Vector2(-1,1) }); + + private static PortalVisibilityFrame FrameWith(params uint[] orderedCells) + { + var f = new PortalVisibilityFrame(); + foreach (var id in orderedCells) + { + f.OrderedVisibleCells.Add(id); + var v = new CellView(); v.Add(Quad()); + f.CellViews[id] = v; + } + return f; + } + + [Fact] + public void ShellPass_IncludesEveryVisibleCell_NoFilter() + { + // The grey bug: a cell in OrderedVisibleCells must NEVER be dropped from the + // shell pass. (Old code dropped cells lacking a ClipFrameAssembler slot.) + var f = FrameWith(0x01, 0x02, 0x03); + var plan = IndoorDrawPlan.ShellPass(f); + Assert.Equal(new uint[] { 0x03, 0x02, 0x01 }, plan.Select(e => e.CellId).ToArray()); // reverse = far→near + Assert.All(plan, e => Assert.NotEmpty(e.Slices)); + } + + [Fact] + public void ShellPass_ExcludesEmptyViewCells() + { + var f = FrameWith(0x01); + f.OrderedVisibleCells.Add(0x02); // present in the list… + f.CellViews[0x02] = new CellView(); // …but empty view → not drawable + var plan = IndoorDrawPlan.ShellPass(f); + Assert.Equal(new uint[] { 0x01 }, plan.Select(e => e.CellId).ToArray()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~IndoorDrawPlanTests"` +Expected: FAIL — `IndoorDrawPlan` does not exist (compile error). + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// IndoorDrawPlan.cs +// +// Pure (GL-free) port of the membership half of retail PView::DrawCells (0x5a4840): +// the reverse cell_draw_list iterated per portal_view slice. EVERY visible cell with a +// non-empty view is included — there is NO "drawable" filter. Dropping cells without a +// clip-slot was the grey-walls bug (the cell's sealed shell never drew → clear color showed). +using System.Collections.Generic; + +namespace AcDream.App.Rendering; + +public readonly record struct CellDrawEntry(uint CellId, IReadOnlyList Slices); + +public static class IndoorDrawPlan +{ + /// Reverse OrderedVisibleCells (far→near), each visible cell with its view + /// slices. Mirrors DrawCells' shell/object loops. Cells whose view is empty are skipped + /// (they are not actually visible); no other cell is ever dropped. + public static List ShellPass(PortalVisibilityFrame frame) + { + var result = new List(frame.OrderedVisibleCells.Count); + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = frame.OrderedVisibleCells[i]; + if (!frame.CellViews.TryGetValue(cellId, out var view) || view.IsEmpty) + continue; + result.Add(new CellDrawEntry(cellId, view.Polygons)); + } + return result; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~IndoorDrawPlanTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```powershell +git add src/AcDream.App/Rendering/IndoorDrawPlan.cs tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs +git commit -m @' +feat(render): IndoorDrawPlan.ShellPass — every visible cell, no drawable filter (R1) + +Pure port of retail DrawCells membership: reverse cell_draw_list, per-slice. Pins the +grey regression — a cell in OrderedVisibleCells is never dropped from the shell pass. + +Co-Authored-By: Claude Opus 4.8 (1M context) +'@ +``` + +--- + +## Task 2: Shell pass draws EVERY visible cell (kill the grey) — highest-value, smallest change + +This is the change that should make the grey disappear. Keep the existing per-slice clip routing for now (Task 4 cleans it); only remove the `drawableCells` filter and iterate `IndoorDrawPlan.ShellPass`. + +**Files:** Modify `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawEnvCellShells`, ~`:175-197`). + +- [ ] **Step 1: Replace the body of `DrawEnvCellShells`** + +Replace the loop so it iterates `IndoorDrawPlan.ShellPass(pvFrame)` (every visible cell) instead of `pvFrame.OrderedVisibleCells` gated by `drawableCells.Contains`. Keep `GetCellSlicesOrNoClip` + `UseShellClipRouting` exactly as they are for this task. New body: + +```csharp +private void DrawEnvCellShells( + IRetailPViewCellDrawCallbacks ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells) // param kept this task; removed in Task 4 +{ + foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) + { + uint cellId = entry.CellId; + _oneCell.Clear(); + _oneCell.Add(cellId); + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + { + UseShellClipRouting(cellId, slice); + _envCells.Render(WbRenderPass.Opaque, _oneCell); + _envCells.Render(WbRenderPass.Transparent, _oneCell); + } + } +} +``` + +Note: `GetCellSlicesOrNoClip` already returns `NoClipSlice` for a cell with no assembler slice, so a cell that used to be dropped now draws **unclipped** — sealed (grey gone), possibly with some bleed (Task 4 fixes the trim). + +- [ ] **Step 2: Build** + +Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 3: Run the App test suite (no regression)** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build` +Expected: 207/207 pass (205 baseline + 2 new). + +- [ ] **Step 4: Launch + VISUAL verify (user)** + +Launch with probes (Appendix). Stand inside the cottage + go to the cellar. **Acceptance:** the interior walls/floor no longer render grey — every room you're in is sealed. Some bleed through doorways is expected at this step. In the log, `[render-sig] draw=[…]` should list the same cells as `ids=[…]` (no cell dropped). **Do not proceed until the user confirms the grey is gone.** + +- [ ] **Step 5: Commit** + +```powershell +git add src/AcDream.App/Rendering/RetailPViewRenderer.cs +git commit -m @' +fix(render): shell pass draws every visible cell — kill the grey (R1) + +Iterate IndoorDrawPlan.ShellPass (all visible cells) instead of gating on the +drawableCells slot filter. A visible cell whose shell was dropped for lack of a +ClipFrameAssembler slot now draws, sealing the interior. Per-slice trim unchanged +this commit (Task 4 replaces it). + +Co-Authored-By: Claude Opus 4.8 (1M context) +'@ +``` + +--- + +## Task 3: Object pass draws every visible cell's objects, no clip (kill the half-character) + +**Files:** Modify `RetailPViewRenderer.cs` (`DrawCellObjectLists`, ~`:199-224`). + +- [ ] **Step 1: Replace the loop to use `IndoorDrawPlan.ShellPass` order + ensure no clip on objects** + +```csharp +private void DrawCellObjectLists( + IRetailPViewCellDrawContext ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells, // kept this task; removed in Task 4 + InteriorEntityPartition.Result partition) +{ + foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) + { + uint cellId = entry.CellId; + if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) + continue; + + _oneCell.Clear(); + _oneCell.Add(cellId); + + UseIndoorMembershipOnlyRouting(); // objects: NO clip planes (retail DrawObjCellForDummies) + DrawEntityBucket(ctx, bucket, _oneCell); + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket)); + } +} +``` + +(Functionally close to today, but iterating the full visible set. `UseIndoorMembershipOnlyRouting` stays here on purpose — objects are never clip-planed. It is removed from the shell path in Task 4.) + +- [ ] **Step 2: Build + App tests** + +Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build` +Expected: build 0 errors; 207/207. + +- [ ] **Step 3: Launch + VISUAL verify (user)** + +Stand on the cellar stairs / near a door with an NPC. **Acceptance:** characters/objects are **whole** (no half-character), and objects in visible cells appear. Confirm with the user before proceeding. + +- [ ] **Step 4: Commit** + +```powershell +git add src/AcDream.App/Rendering/RetailPViewRenderer.cs +git commit -m @' +fix(render): object pass over every visible cell, no clip planes (R1) + +Objects drawn membership+depth gated (retail DrawObjCellForDummies), never hard-clipped +to the 2D portal view — fixes the half-character. Iterates the full visible-cell set. + +Co-Authored-By: Claude Opus 4.8 (1M context) +'@ +``` + +--- + +## Task 4: Per-slice shell trim from `ClipPlaneSet.From` directly (drop the ClipFrameAssembler dependency in the shell loop) + +Now make the shell trim faithful and self-contained: compute each slice's planes from the cell's own `CellView.Polygons` via `ClipPlaneSet.From`, pack one region into `ClipFrame`, route the cell to it. This removes the shell loop's use of `clipAssembly` and `drawableCells`. + +**Files:** Modify `RetailPViewRenderer.cs` (`DrawEnvCellShells`, helpers). + +- [ ] **Step 1: Add a per-slice shell-clip helper** + +```csharp +// Pack ONE slice's convex region into the clip frame (slot 1) and route this cell to it, +// matching retail setup_view(cell, slice). Slot 0 stays no-clip. Returns false when the +// slice is empty (draw nothing) — the caller skips it. +private bool ApplyShellSliceClip(uint cellId, ViewPolygon slice, Action setTerrainClipUbo) +{ + var oneSlice = new CellView(); + oneSlice.Add(slice); + var planes = ClipPlaneSet.From(oneSlice); + if (planes.IsNothingVisible) + return false; + + _clipFrame.Reset(); // slot 0 = no-clip + int slot = _clipFrame.AppendSlot(planes); // slot 1 = this slice (or no-clip region if scissor fallback*) + UploadClipFrame(setTerrainClipUbo); // re-upload SSBO (cheap; few indoor cells) + + _oneCellSlot.Clear(); + _oneCellSlot[cellId] = slot; + _envCells.SetClipRouting(_oneCellSlot); + _entities.ClearClipRouting(); + return true; +} +``` + +\* `ClipFrame.AppendSlot(ClipPlaneSet)` packs the planes when `Count>0`, else a no-clip region. The scissor-AABB fallback (`UseScissorFallback`) is not expressible as planes — for this first port, treat scissor-fallback as no-clip (over-include, never grey). A later refinement can `glScissor` the `ScissorNdcAabb`; note it in the handoff if you see bleed only on >8-edge slices. + +- [ ] **Step 2: Rewrite `DrawEnvCellShells` to use it (and drop `clipAssembly`/`drawableCells` params)** + +```csharp +private void DrawEnvCellShells( + RetailPViewDrawContext ctx, // need SetTerrainClipUbo; use the concrete context + PortalVisibilityFrame pvFrame) +{ + foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) + { + _oneCell.Clear(); + _oneCell.Add(entry.CellId); + + foreach (var slice in entry.Slices) + { + if (!ApplyShellSliceClip(entry.CellId, slice, ctx.SetTerrainClipUbo)) + continue; + _envCells.Render(WbRenderPass.Opaque, _oneCell); + _envCells.Render(WbRenderPass.Transparent, _oneCell); + } + } +} +``` + +(For `DrawPortal`, which uses `RetailPViewPortalDrawContext`, either add `SetTerrainClipUbo` access via the shared `IRetailPViewCellDrawContext` interface or pass the `Action` directly. Both contexts already expose `SetTerrainClipUbo`.) + +Update the two call sites (`DrawInside` `:77`, `DrawPortal` `:126`) to `DrawEnvCellShells(ctx, pvFrame)`. + +- [ ] **Step 3: Build + App tests** + +Run: build + `dotnet test … --no-build`. Expected: 0 errors; 207/207 (the deleted clip-slot routing isn't unit-tested). + +- [ ] **Step 4: Launch + VISUAL verify (user)** + +**Acceptance:** interior is sealed AND trimmed — looking through a doorway shows only the slice of the next room visible through the opening; no bleed of a neighbour room past a wall edge; no shell gap at stair/door boundaries. If a shell gaps, the slice is too small (a `ClipToRegion` case to inspect) — report it; do not re-add the filter. Confirm with the user. + +- [ ] **Step 5: Commit** + +```powershell +git add src/AcDream.App/Rendering/RetailPViewRenderer.cs +git commit -m @' +feat(render): per-slice shell trim from ClipPlaneSet.From (retail setup_view) (R1) + +Each visible cell's shell is clipped per portal_view slice via ClipPlaneSet→gl_ClipDistance, +computed from the cell's own CellView — no ClipFrameAssembler slot pool, no drawableCells +filter. Objects remain unclipped. + +Co-Authored-By: Claude Opus 4.8 (1M context) +'@ +``` + +--- + +## Task 5: Look-out (landscape through OutsideView) from `OutsideView` slices directly + +Drop the shell/object loops' last `clipAssembly` use and make the landscape pass read `pvFrame.OutsideView` directly. + +**Files:** Modify `RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` `:133-153`, `DrawInside` `:74`). + +- [ ] **Step 1: Rewrite `DrawLandscapeThroughOutsideView` to iterate `pvFrame.OutsideView.Polygons`** + +```csharp +private void DrawLandscapeThroughOutsideView( + RetailPViewDrawContext ctx, + PortalVisibilityFrame pvFrame, + InteriorEntityPartition.Result partition) +{ + if (pvFrame.OutsideView.IsEmpty) + return; + + foreach (var slicePoly in pvFrame.OutsideView.Polygons) + { + var oneSlice = new CellView(); + oneSlice.Add(slicePoly); + var planes = ClipPlaneSet.From(oneSlice); + if (planes.IsNothingVisible) + continue; + + _clipFrame.SetTerrainClip(planes.Count > 0 ? ToSpan(planes) : ReadOnlySpan.Empty); + UploadClipFrame(ctx.SetTerrainClipUbo); + // Reuse the existing ClipViewSlice DTO ONLY as the landscape callback payload, or + // introduce a small RetailPViewLandscapeSliceContext from (planes, slicePoly bounds). + ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(/* slice payload */, partition.Outdoor)); + } + + // depth-clear over the OutsideView bounds (retail Clear(DEPTH) when outside_view.view_count>0) + ctx.ClearDepthSlice?.Invoke(/* OutsideView NDC bounds */); +} + +private static ReadOnlySpan ToSpan(ClipPlaneSet s) +{ + var a = new Vector4[s.Count]; + for (int i = 0; i < s.Count; i++) a[i] = s.Planes[i]; + return a; +} +``` + +Note: `RetailPViewLandscapeSliceContext` / `ClearDepthSlice` currently take a `ClipViewSlice`. Simplest path: keep a tiny `ClipViewSlice` record (just `{ Planes, NdcAabb }`) as a payload DTO even after deleting `ClipFrameAssembler`, OR change those two callback signatures to take `(ReadOnlySpan planes, Vector4 ndcAabb)`. Pick one and apply consistently to the `GameWindow` callback implementations (`DrawRetailPViewLandscapeSlice`, the `ClearDepthSlice` lambda at `GameWindow.cs:7480`). + +- [ ] **Step 2: Update `DrawInside` to call the new signatures and drop `clipAssembly`/`drawableCells`** + +In `DrawInside` (`:39-81`): remove `var clipAssembly = ClipFrameAssembler.Assemble(...)`, `drawableCells`, and the `UseIndoorMembershipOnlyRouting()` calls around the shell loop. Partition with **all** visible cells: + +```csharp +var partition = InteriorEntityPartition.Partition( + new HashSet(pvFrame.OrderedVisibleCells), ctx.LandblockEntries); +``` + +Final `DrawInside` order: `Build` → `_envCells.PrepareRenderBatches(filter: visibleSet)` → `DrawLandscapeThroughOutsideView(ctx, pvFrame, partition)` → `DrawExitPortalMasks` (if kept) → `DrawEnvCellShells(ctx, pvFrame)` → `DrawCellObjectLists(ctx, pvFrame, partition)`. + +- [ ] **Step 3: Build + App tests + Launch VISUAL verify (user)** + +Build 0 errors; 207/207. **Acceptance:** standing inside, looking out a door/window shows the outdoor world through the opening (not grey, not full-screen); depth correct. Confirm with the user. + +- [ ] **Step 4: Commit** + +```powershell +git add src/AcDream.App/Rendering/RetailPViewRenderer.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m @' +feat(render): look-out landscape from OutsideView slices; drop clipAssembly in DrawInside (R1) + +DrawInside no longer builds a ClipFrameAssembler; landscape-through-outside_view reads +pvFrame.OutsideView directly per slice. Partition runs over all visible cells. + +Co-Authored-By: Claude Opus 4.8 (1M context) +'@ +``` + +--- + +## Task 6: Delete `ClipFrameAssembler` + the slot-pool dead code + +After Tasks 4–5 nothing should reference `ClipFrameAssembler` / `ClipFrameAssembly` / `drawableCells` in the draw path. + +**Files:** Delete `ClipFrameAssembler.cs`, `ClipFrameAssemblerTests.cs`; edit `RetailPViewRenderer.cs`, `GameWindow.cs`. + +- [ ] **Step 1: Remove remaining references** + - `RetailPViewRenderer.cs`: remove `using`/fields/params for `ClipFrameAssembly`, `drawableCells`, `_oneCellSlot` if now unused, `GetCellSlicesOrNoClip`, `UseShellClipRouting`, and `RetailPViewFrameResult.ClipAssembly`/`DrawableCells` (keep `PortalFrame`, `Partition`). Update `DrawExitPortalMasks` to iterate `IndoorDrawPlan.ShellPass` + `entry.Slices` (or delete it if exit masks are not needed — see note). + - `GameWindow.cs`: `sigTerrainDrawn`/`sigSkyDrawn`/`sigDepthClear`/`sigSceneParticles` (`:7506-7514`) currently read `pviewResult.ClipAssembly.OutsideViewSlices.Length`; change to `pviewResult.PortalFrame.OutsideView.Polygons.Count`. Remove other `ClipAssembly`/`DrawableCells` reads. + + **Exit-masks note:** retail Loop 1 (`DrawPortalPolyInternal`) punches exit-portal openings into depth for the look-out landscape. If, after Task 5, the look-out landscape shows correctly without it, delete `DrawExitPortalMasks`. If the outdoor world z-fights or is occluded wrong through the opening, keep it and port it as: per visible cell, per slice, draw the cell's exit-portal polygons depth-only. Decide from the Task 5 visual. + +- [ ] **Step 2: Delete the files** + +```powershell +git rm src/AcDream.App/Rendering/ClipFrameAssembler.cs tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs +``` + +If `ClipViewSlice`/`ClipFrameAssembly` live in `ClipFrameAssembler.cs` and a payload DTO is still needed (Task 5), move the minimal record to a new small file `RetailPViewTypes.cs` (or inline into `RetailPViewRenderer.cs`). + +- [ ] **Step 3: Build + FULL test suite** + +Run: `dotnet build -c Debug --nologo` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build` and `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj -c Debug --no-build`. +Expected: build 0 errors; App green (now ~205–207 depending on removed assembler tests); Core 1331/4(pre-existing)/1. + +- [ ] **Step 4: Commit** + +```powershell +git add -A +git commit -m @' +refactor(render): delete ClipFrameAssembler slot-pool + drawableCells filter (R1) + +The verbatim DrawCells loop (Tasks 1–5) no longer needs the slot pool or the +drawable-cells filter that dropped shells. Removes the approximation layer. + +Co-Authored-By: Claude Opus 4.8 (1M context) +'@ +``` + +--- + +## Task 7: Look-in (B) — `DrawPortal` on the same loops + +`DrawPortal` already calls `DrawEnvCellShells`/`DrawCellObjectLists`; after Tasks 4–6 those are filter-free and clip-correct, so look-in should "just work." This task verifies it and removes any residual `clipAssembly` use in `DrawPortal`. + +**Files:** Modify `RetailPViewRenderer.cs` (`DrawPortal` `:83-131`). + +- [ ] **Step 1: Mirror DrawInside's cleanup in DrawPortal** + +Remove `ClipFrameAssembler.Assemble`, `drawableCells`; partition over `pvFrame.OrderedVisibleCells`; call `DrawEnvCellShells(ctx, pvFrame)` + `DrawCellObjectLists(ctx, pvFrame, partition)`; keep the `RestoreNoClip` at the end. + +- [ ] **Step 2: Build + App tests** + +Build 0 errors; App green. + +- [ ] **Step 3: Launch + VISUAL verify (user)** + +Walk up to a building from OUTSIDE with an open door. **Acceptance:** you see the room interior (shell + objects) through the doorway as you approach, sealed and trimmed; walking through the door is seamless (no flash/grey at the threshold). Confirm with the user. + +- [ ] **Step 4: Commit** + +```powershell +git add src/AcDream.App/Rendering/RetailPViewRenderer.cs +git commit -m @' +feat(render): look-in DrawPortal on the verbatim DrawCells loops (R1, scope B) + +Outside-looking-in reuses the same per-cell, per-slice shell + object passes as DrawInside; +no separate engine, no slot pool. + +Co-Authored-By: Claude Opus 4.8 (1M context) +'@ +``` + +--- + +## Task 8: Final verification + handoff + +- [ ] **Step 1: Full green** + +`dotnet build -c Debug --nologo`; App + Core test suites. Record counts. + +- [ ] **Step 2: Clean launch (no probes) for FPS/feel + the four seamless scenarios** + +With the user: (1) stand inside cottage — sealed, textured-or-grey noted; (2) cellar — floor + stairs present, character whole; (3) room↔room↔cellar transitions — no flap; (4) walk outside→in and look in through a door — seamless. Get explicit visual sign-off on each. + +- [ ] **Step 3: If walls are sealed but UNTEXTURED (grey-but-drawn)** + +That is the separate surface/texture bug the spec flags as out of scope (HEAD's "interior walls grey"). File it as a new issue in `docs/ISSUES.md` with the evidence (every shell now draws per `[render-sig]`, but surfaces render untextured) — do NOT reopen the membership/clip work for it. + +- [ ] **Step 4: Update memory + roadmap** + +If the seal holds and the user confirms: add a `memory/` entry (e.g. `feedback_verbatim_drawcells_port.md`) capturing that the grey was the `drawableCells` filter + the verbatim `DrawCells` loop (every cell, per-slice shell clip, objects unclipped) fixed it; update `MEMORY.md`. Note the two-handoff contradiction (2026-06-05 shell-sealing vs 2026-06-06 projection) so it isn't re-litigated. + +--- + +## Self-Review (done while writing — recorded for the executor) + +- **Spec coverage:** §4.2 loop → Tasks 2/3/4 (shells/objects/trim); §4.3 look-in → Task 7; §4 look-out → Task 5; §4.1 deletions → Task 6; testing §7 → Task 1 (pure pin) + per-task visual gates; risks §8 → Task 4 Step 4 note + Task 8 Step 3. Covered. +- **Type consistency:** `IndoorDrawPlan.ShellPass` → `List`; `CellDrawEntry(uint CellId, IReadOnlyList Slices)`; `ClipPlaneSet.From(CellView)`; `ClipFrame.Reset()/AppendSlot/SetTerrainClip/UploadShared`; `EnvCellRenderer.SetClipRouting/Render`. Consistent across tasks. +- **Known soft spots (flagged inline, not placeholders):** (a) the landscape/depth-clear callback DTO (`ClipViewSlice` vs a new `(planes, aabb)`) — Task 5 Step 1 picks one explicitly; (b) exit-masks keep-or-delete — Task 6 Step 1 decides from the Task 5 visual; (c) scissor-AABB fallback rendered as no-clip for now — Task 4 note. These are genuine implementation choices for the executor, each with a stated default. + +--- + +## Appendix: launch command (probes) + +```powershell +Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | ForEach-Object { $_.CloseMainWindow() | Out-Null; if (-not $_.WaitForExit(5000)) { $_ | Stop-Process -Force } } +Start-Sleep -Seconds 3 +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" +$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" +$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" +$env:ACDREAM_PROBE_FLAP="1"; $env:ACDREAM_PROBE_SHELL="1"; $env:ACDREAM_PROBE_VIS="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-drawcells.log" +``` + +Run in the background; the user tests then closes the window. For a clean FPS/feel run, drop the three `ACDREAM_PROBE_*` vars. **Build before every launch.** Heavy probe output can make the client sluggish — keep probe runs short. From bff19550660428df96f759885d21ba5a1e1f6ea9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 6 Jun 2026 21:59:26 +0200 Subject: [PATCH 025/172] =?UTF-8?q?feat(render):=20IndoorDrawPlan.ShellPas?= =?UTF-8?q?s=20=E2=80=94=20every=20visible=20cell,=20no=20drawable=20filte?= =?UTF-8?q?r=20(R1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure port of retail DrawCells membership: reverse cell_draw_list, per-slice. Pins the grey regression — a cell in OrderedVisibleCells is never dropped from the shell pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/IndoorDrawPlan.cs | 30 ++++++++++++ .../Rendering/IndoorDrawPlanTests.cs | 46 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/AcDream.App/Rendering/IndoorDrawPlan.cs create mode 100644 tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs diff --git a/src/AcDream.App/Rendering/IndoorDrawPlan.cs b/src/AcDream.App/Rendering/IndoorDrawPlan.cs new file mode 100644 index 00000000..a3aab2a2 --- /dev/null +++ b/src/AcDream.App/Rendering/IndoorDrawPlan.cs @@ -0,0 +1,30 @@ +// IndoorDrawPlan.cs +// +// Pure (GL-free) port of the membership half of retail PView::DrawCells (0x5a4840): +// the reverse cell_draw_list iterated per portal_view slice. EVERY visible cell with a +// non-empty view is included — there is NO "drawable" filter. Dropping cells without a +// clip-slot was the grey-walls bug (the cell's sealed shell never drew → clear color showed). +using System.Collections.Generic; + +namespace AcDream.App.Rendering; + +public readonly record struct CellDrawEntry(uint CellId, IReadOnlyList Slices); + +public static class IndoorDrawPlan +{ + /// Reverse OrderedVisibleCells (far→near), each visible cell with its view + /// slices. Mirrors DrawCells' shell/object loops. Cells whose view is empty are skipped + /// (they are not actually visible); no other cell is ever dropped. + public static List ShellPass(PortalVisibilityFrame frame) + { + var result = new List(frame.OrderedVisibleCells.Count); + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = frame.OrderedVisibleCells[i]; + if (!frame.CellViews.TryGetValue(cellId, out var view) || view.IsEmpty) + continue; + result.Add(new CellDrawEntry(cellId, view.Polygons)); + } + return result; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs b/tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs new file mode 100644 index 00000000..6b0de649 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class IndoorDrawPlanTests +{ + private static ViewPolygon Quad() => new(new[] + { new Vector2(-1, -1), new Vector2(1, -1), new Vector2(1, 1), new Vector2(-1, 1) }); + + private static PortalVisibilityFrame FrameWith(params uint[] orderedCells) + { + var f = new PortalVisibilityFrame(); + foreach (var id in orderedCells) + { + f.OrderedVisibleCells.Add(id); + var v = new CellView(); v.Add(Quad()); + f.CellViews[id] = v; + } + return f; + } + + [Fact] + public void ShellPass_IncludesEveryVisibleCell_NoFilter() + { + // The grey bug: a cell in OrderedVisibleCells must NEVER be dropped from the + // shell pass. (Old code dropped cells lacking a ClipFrameAssembler slot.) + var f = FrameWith(0x01, 0x02, 0x03); + var plan = IndoorDrawPlan.ShellPass(f); + Assert.Equal(new uint[] { 0x03, 0x02, 0x01 }, plan.Select(e => e.CellId).ToArray()); // reverse = far→near + Assert.All(plan, e => Assert.NotEmpty(e.Slices)); + } + + [Fact] + public void ShellPass_ExcludesEmptyViewCells() + { + var f = FrameWith(0x01); + f.OrderedVisibleCells.Add(0x02); // present in the list… + f.CellViews[0x02] = new CellView(); // …but empty view → not drawable + var plan = IndoorDrawPlan.ShellPass(f); + Assert.Equal(new uint[] { 0x01 }, plan.Select(e => e.CellId).ToArray()); + } +} From 1405dd8e9009515f13b3b41937b804d128a0af29 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 10:14:43 +0200 Subject: [PATCH 026/172] =?UTF-8?q?feat(render):=20indoor=20render=20WORKS?= =?UTF-8?q?=20=E2=80=94=20terminating=20portal=20flood=20+=20every-cell=20?= =?UTF-8?q?seal=20+=20look-in=20FPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the interior seals (live-verified by the user). Commits the session render-rewrite foundation together with the fixes that made it functional. - HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit); only its count is capped. CellViewDedupTests added. - Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey). - Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81 loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled). Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway, confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight): docs/research/2026-06-07-indoor-render-session-handoff.md. Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...5-retail-pview-indoor-render-pseudocode.md | 135 +++ ...2026-06-06-indoor-render-hang-rootcause.md | 163 +++ ...w-renderer-replacement-attempts-handoff.md | 589 ++++++++++ ...026-06-07-indoor-render-session-handoff.md | 160 +++ .../Rendering/ClipFrameAssembler.cs | 295 +++-- src/AcDream.App/Rendering/ClipPlaneSet.cs | 1 - src/AcDream.App/Rendering/GameWindow.cs | 1015 ++++++++++++----- .../Rendering/InteriorEntityPartition.cs | 56 +- src/AcDream.App/Rendering/InteriorRenderer.cs | 108 +- src/AcDream.App/Rendering/ParticleRenderer.cs | 10 +- src/AcDream.App/Rendering/PortalProjection.cs | 111 ++ src/AcDream.App/Rendering/PortalView.cs | 78 +- .../Rendering/PortalVisibilityBuilder.cs | 452 +++++++- .../Rendering/RetailPViewRenderer.cs | 374 ++++++ .../Rendering/Wb/ObjectMeshManager.cs | 65 +- .../Rendering/Wb/WbDrawDispatcher.cs | 49 +- .../Rendering/RenderingDiagnostics.cs | 14 +- src/AcDream.Core/World/WorldEntity.cs | 7 +- .../Rendering/CellViewDedupTests.cs | 64 ++ .../Rendering/ClipFrameAssemblerTests.cs | 174 +-- .../Rendering/ClipPlaneSetTests.cs | 1 + .../Rendering/InteriorEntityPartitionTests.cs | 39 +- .../Rendering/PortalProjectionTests.cs | 171 +++ .../Rendering/PortalVisibilityBuilderTests.cs | 166 +++ .../Wb/WbDrawDispatcherClipSlotTests.cs | 98 +- .../Rendering/RenderingDiagnosticsTests.cs | 14 +- tools/TextureDump/Program.cs | 40 +- 27 files changed, 3635 insertions(+), 814 deletions(-) create mode 100644 docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md create mode 100644 docs/research/2026-06-06-indoor-render-hang-rootcause.md create mode 100644 docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md create mode 100644 docs/research/2026-06-07-indoor-render-session-handoff.md create mode 100644 src/AcDream.App/Rendering/RetailPViewRenderer.cs create mode 100644 tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs diff --git a/docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md b/docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md new file mode 100644 index 00000000..9b5c16ad --- /dev/null +++ b/docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md @@ -0,0 +1,135 @@ +# Retail PView Indoor Render Pseudocode (2013 EoR) + +This note pins the indoor render port to the named retail decomp. The goal is +behavioral fidelity: modern GL renderers may supply the draw calls, but the +frame ownership, visibility graph, and draw order follow these functions. + +## SmartBox::RenderNormalMode @ 0x00453aa0 + +```text +if render device has open scene: + outside = SmartBox::is_player_outside(player position) + seenOutside = outside || viewer_cell.seen_outside + set FOV/view distance + + if !outside: + if seenOutside: + LScape::update_viewpoint(lscape, Position::get_outside_cell_id(viewer)) + Render::update_viewpoint(viewer) + RenderDeviceD3D::DrawInside(viewer_cell) + else: + LScape::update_viewpoint(lscape, viewer.objcell_id) + Render::update_viewpoint(viewer) + Render::set_default_view() + Render::useSunlightSet(1) + LScape::draw(lscape) + +FlushAlphaList() +run targeting/render callbacks +``` + +Important split: the top-level branch follows `is_player_outside`, while indoor +render calls `DrawInside(viewer_cell)`. + +## RenderDeviceD3D::DrawInside @ 0x0059f0d0 + +```text +PView::DrawInside(RenderDeviceD3D::indoor_pview, viewer_cell) +``` + +This is a thin forwarder. The PView owns the indoor frame. + +## PView::DrawInside @ 0x005a5860 + +```text +reset object scale +CEnvCell::curr_view_push(root_cell) +PView::add_views(root_cell.num_stabs, root_cell.stab_list) +Frame::cache() +Render::positionPush(root identity frame) +Render::copy_view(root_cell.portal_view[last], null, 4) # full-screen root view +forceClear = PView::ConstructView(root_cell, 0xffff) +PView::DrawCells(forceClear) +Render::framePop() +PView::remove_views(root_cell.num_stabs, root_cell.stab_list) +root_cell.num_view-- +``` + +## PView::ConstructView(CEnvCell*) @ 0x005a57b0 + +```text +clear outside_view and cell draw/todo state +insert root cell into distance-priority todo list + +while todo is not empty: + cell = pop nearest + append cell to cell_draw_list + InitCell(cell, otherPortalId) + project/clip each portal against the current cell view + exit portals append clipped polygons to outside_view + interior portals append clipped polygons to neighbor portal_view + newly discovered neighbors enter the todo list once + +return forceClear flag +``` + +`cell_draw_list` is the only indoor membership source. Later growth can add view +polygons to a discovered cell, but does not create a second draw-list entry. + +## PView::DrawCells @ 0x005a4840 + +```text +if outside_view.view_count > 0: + Render::useSunlightSet(1) + Render::PortalList = this + LScape::draw(lscape) # landscape clipped by outside_view + D3DPolyRender::FlushAlphaList(0) + render_device.frameStamp++ + if forceClear || portalsDrawnCount != 0: + render_device.Clear(DepthOnly) + + # Loop 1: exit portal masks, reverse cell_draw_list + for cell in reverse(cell_draw_list): + if cell.structure.drawing_bsp: + push cell frame and surfaces + for each current portal_view slice: + CEnvCell::setup_view(cell, slice) + for each exit portal: + DrawPortalPolyInternal(portal polygon) + pop frame + +Render::useSunlightSet(0) +Render::restore_all_lighting() + +# Loop 2: closed cell shells, reverse cell_draw_list +for cell in reverse(cell_draw_list): + if cell.structure.drawing_bsp: + push cell frame and surfaces + for each current portal_view slice: + CEnvCell::setup_view(cell, slice) + DrawEnvCell(cell) + pop frame + +# Loop 3: cell object lists, reverse cell_draw_list +for cell in reverse(cell_draw_list): + Render::PortalList = cell.portal_view[last] + DrawObjCellForDummies(cell) + +restore object scale +Render::useSunlightSet(1) +``` + +There is no global indoor object, terrain, sky, weather, or particle pass. Every +visible indoor object comes from the cell draw list, and the landscape appears +only through `outside_view`. + +## RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760 + +```text +for object in cell.object_list: + draw object under Render::PortalList + attached effects/particles follow the owning object visibility +``` + +acdream maps this to per-cell `WorldEntity.ParentCellId` buckets. Parentless +live objects must not bypass the indoor PView graph. diff --git a/docs/research/2026-06-06-indoor-render-hang-rootcause.md b/docs/research/2026-06-06-indoor-render-hang-rootcause.md new file mode 100644 index 00000000..03f72db5 --- /dev/null +++ b/docs/research/2026-06-06-indoor-render-hang-rootcause.md @@ -0,0 +1,163 @@ +# Indoor render HANG — root cause: `PortalVisibilityBuilder.Build` non-termination — 2026-06-06 + +> Report-only investigation (user chose "investigate more first"). **No code changed.** +> Worktree `thirsty-goldberg-51bb9b`. This blocks the verbatim-DrawCells port's Task 2 +> visual gate: every indoor frame can freeze here. + +## Symptom + +Three launches of the client all **froze** (`AppHangB1`, Windows Event Log) within +seconds-to-minutes of the camera being indoors at the Holtburg cottage. Not a crash — +no access violation, no managed exception. The captured managed stack of the frozen +render thread (`hang-stack.txt`, via `dotnet-stack`) shows it **CPU-spinning**: + +``` +CPU_TIME +CellView.Add(ViewPolygon) +PortalVisibilityBuilder.AddRegion(CellView, List) +PortalVisibilityBuilder.Build(...) +RetailPViewRenderer.DrawInside(...) +GameWindow.OnRender(...) +``` + +App.Tests 207/207 and Core 1331/4/1 are green; the bug is invisible to the suite (see §Evidence). + +## Verdict + +**It is NOT Task 2 (the verbatim-DrawCells / grey fix).** `Build(...)` runs at the very +top of `DrawInside` ([RetailPViewRenderer.cs:43](../../src/AcDream.App/Rendering/RetailPViewRenderer.cs)), +**before** any line Task 2 touched, and the call is byte-identical pre/post-change. Task 2's +draw logic was independently confirmed correct in the run-1 log: `[render-sig] draw=[…]` +equalled `ids=[…]` with `miss=[]`, and `[shell]` showed every visible cell drawing textured +(`zh=0`). The grey fix works. + +**Root cause:** `PortalVisibilityBuilder.Build`'s portal BFS does not terminate for real +cottage geometry. It **re-enqueues a popped cell every time that cell's `CellView` grows**: +`queued.Remove(cell.CellId)` on pop ([:122](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) ++ `if (grew && queued.Add(neighbourId))` on grow ([:289](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)). +Termination therefore depends entirely on growth stopping. Growth is gated only by +`CellView.Add`'s **exact-match dedup** (`SamePolygon`, eps `1e-4`, +[PortalView.cs:79](../../src/AcDream.App/Rendering/PortalView.cs)). The **near-side portal clip** +(`ClipPortalAgainstView` → `PortalProjection.ProjectToClip` → `ClipToRegion`, +[:474/:485](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) produces a polygon +that is a hair different on each `A↔B` reciprocal round (float drift through the homogeneous +project→clip round-trip with a non-identity cell transform). The dedup never matches the +drifted near-duplicate → the region grows without bound → the cell re-enqueues forever → +`CellView.Polygons` grows to N, and `CellView.Add`'s O(N) dedup scan makes the whole thing +O(N²) → frozen. + +## Evidence + +1. **Captured stack** pins the spin to `CellView.Add ← AddRegion ← Build`, pure managed + `CPU_TIME` (not a GL call, not blocked, not a fault). +2. **The code already documents this exact failure** at + [:694-697](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs): the *reciprocal* + clip deliberately stays on the float-stable `ProjectToNdc` path *because* + "per-round float drift defeated the CellView SamePolygon dedup, inflating a tight A<->B + reciprocal view to ~4x its area." The **near-side** clip ([:474](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) + did not get the same treatment — it uses `ProjectToClip`. +3. **The only bound was removed this session.** [:74](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs): + "Fixpoint termination replacing the old `MaxReprocessPerCell` hard cap." The fixpoint never + converges under drift; with the cap gone there is no other bound (no iteration cap, no + max-polygon cap, no time bound). +4. **It's the dirty-tree rewire the handoff said to KEEP.** `git diff --stat`: + `PortalVisibilityBuilder.cs +426/−45` and `PortalProjection.cs +111` are **uncommitted**. + `ProjectToClip` is part of the new `PortalProjection` lines. The handoff + (`2026-06-06-verbatim-drawcells-port-pickup-handoff.md`) lists this rewire as the faithful + foundation to preserve and says "the clip math is already faithful — do not harden the + w-clip." The clip is faithful in the *picture* it computes; it is the *non-termination* + that is broken. +5. **Why the suite is green:** `PortalVisibilityBuilderTests` build cells with + `WorldTransform = Matrix4x4.Identity` and axis-aligned quads in 2-cell **chains** + (`cam → ground → exit`). No `A↔B` cycle, no transform-induced drift → the project→clip + round-trip is exact → the dedup collapses duplicates → the BFS converges. The real cottage + is a **cyclic** cell cluster (`0x016F–0x0175`, mutual portals) with **non-identity** + transforms → drift + cycle → non-termination. The suite cannot reach the failing case. +6. **Why run 1 survived 113 frames then froze:** `Build` converges at most camera poses; only + specific poses create the non-converging drift cycle. The freeze coincided with the + metastable doorway flip (`[render-sig] stable` went 39→0, visible-cell count 5→4) one frame + before the log ended. + +## Hypotheses (ranked) + +1. **(confirmed)** Non-terminating BFS: re-enqueue-on-grow + `ProjectToClip` drift defeats the + `SamePolygon` dedup → unbounded `CellView` growth. Falsify: a re-process cap, a + drift-tolerant dedup, or `ProjectToNdc` on the near-side clip all make `Build` terminate. +2. *(ruled out)* GPU/driver hang from a malformed draw — the stack is pure managed `CPU_TIME` + in `CellView.Add`, never a GL call; no fault. +3. *(ruled out)* Probe-output stdout saturation — disproven: the probe-free run also hung. +4. *(ruled out)* Task 2 — `Build` is upstream of every Task 2 line and unchanged by it. + +## Fix options (all additive — none reverts the dirty tree) + +| | Fix | Touches | Pro | Con | +|---|---|---|---|---| +| **A** *(rec.)* | **Drift-tolerant dedup**: round clipped polygon vertices to a small grid (≈`1e-3`) before `AddRegion`, or widen/snaps `SamePolygon`'s match, so near-duplicates collapse → growth converges. | `CellView`/`AddRegion` | Fixes the actual root cause ("drift defeats dedup"); keeps the faithful `ProjectToClip`; preserves growth-propagation. ~10 lines. | Tolerance is a tuning constant (pick conservatively; over-merge = minor over-tighten). | +| **B** | **Restore a re-process bound** (`MaxReprocessPerCell`-style cap on the BFS). | `Build` loop | Smallest; guarantees termination; doesn't touch clip. | A guard, not a root fix; may under-include a late-growing view. The user's "no workarounds" rule applies — this is the band-aid. | +| **C** | **Near-side clip on `ProjectToNdc`** (what the reciprocal clip already uses). | `ClipPortalAgainstView` | Removes the drift source directly; consistent with `:694`. | Steps on this session's homogeneous near-eye clip work; the handoff's "don't harden the w-clip" is closest to here. | + +**Recommended next step:** approve **A** (drift-tolerant dedup) — it closes the precise +mechanism the code half-acknowledges at `:694`, terminates structurally, and leaves the +faithful clip path intact. Implement in a follow-up (not report-only) session, then re-run the +Task 2 visual gate (probe-free) at the cottage + cellar. + +## What this is NOT + +- **NOT** Task 2 / the grey fix — that is verified working (`draw==ids`, `miss=[]`, textured shells). +- **NOT** a wrong-pixels / unfaithful-projection bug — it's a **termination** bug. The handoff's + "the clip math is faithful, don't harden the w-clip" is about projection *correctness*; this is + BFS *convergence*. Don't chase the w-clip. +- **NOT** a GPU/shader/driver hang and **NOT** the probe firehose (both ruled out by the stack + and the probe-free repro). + +--- + +## Reassessment — is the dirty-tree builder rewire sound? (post Option A) + +Option A (drift-tolerant `CellView.Add` dedup, `CellViewDedupTests` green) was implemented and the +client relaunched. Result: the hang **moved out of `CellView.Add`** (A worked for its target) but +**relocated to `ScreenPolygonClip.ClipByEdge`** via `ApplyReciprocalClip` (second captured stack, +`hang-stack2.txt`). `ScreenPolygonClip.Intersect`/`ClipByEdge` are **both bounded `for` loops** — +they cannot spin on one call — so the spin is the **outer `Build` BFS** still not terminating and +calling them a runaway number of times. **Option A is necessary but not sufficient.** + +### Git evidence (what the dirty rewire changed re: termination) + +- **HEAD (committed)** near-side portal clip = `PortalProjection.ProjectToNdc` (float-stable; + `git show HEAD:` line 146). **The dirty rewire switched it to `ProjectToClip`** (`ClipPortalAgainstView`, + dirty line 474) — the homogeneous near-eye clip, introduced to fix the near/grazing-doorway flap/void. +- The `MaxReprocessPerCell` **hard cap was removed earlier** (committed Phase U.2a `d880775`), replaced + by "fixpoint termination." **Neither HEAD nor the dirty tree has a hard iteration bound.** +- The dirty rewire's own comment (`PortalVisibilityBuilder.cs:519-522`) documents that + `ProjectToClip` "produced per-round float drift that defeated the CellView SamePolygon dedup" — and + applied that lesson **only to the reciprocal clip** (kept on `ProjectToNdc`), leaving the **near-side** + clip on the drift-prone `ProjectToClip`. + +### Soundness verdict + +The builder's termination model is **unsound by construction.** It relies on the clipped regions +reaching a geometric fixpoint — re-clipping a cell's view reproduces *exactly-equal* polygons that the +dedup recognises — with **no hard iteration bound.** That only holds if the clip is float-stable. +`ProjectToClip` (needed for faithful near-doorway projection) injects per-round drift, so re-clipping +never reproduces an exactly-equal polygon, the dedup never catches it, and the re-enqueue-on-grow flood +never converges → infinite loop. **You cannot have BOTH faithful near-doorway projection (`ProjectToClip`) +AND convergence-via-exact-dedup-without-a-bound.** HEAD got away with it because `ProjectToNdc` was +stable enough to converge (and it sealed — user-verified); the dirty switch tipped it into non-termination. +The rewire fixed the *projection* and, apparently never having been launched, shipped a hang. + +A's drift-tolerant dedup *narrows* the gap but cannot *close* it: for some geometry the per-round drift +exceeds any fixed snap grid, so growth still produces new keys forever. Only a **hard bound** guarantees +termination. + +### Paths (for the user to choose) + +| | Path | Termination | Projection fidelity | Risk | +|---|---|---|---|---| +| **1** *(rec.)* | Keep `ProjectToClip` + add **enqueue-once** bound (D) — the builder's own comment already calls enqueue-once "the hard termination guarantee"; the re-enqueue-on-grow is the bug. Keep A. | Guaranteed (≤N pops) | Full (faithful doorway clip kept) | Minor under-inclusion of late growth → visual-verify; widen to a cap if needed | +| **2** | Keep `ProjectToClip` + add a **re-process cap** (B, restore `MaxReprocessPerCell`). Keep A. | Guaranteed (≤N×K) | Full | Less faithful than enqueue-once; a tuning constant | +| **3** | **Revert** the near-side `ProjectToClip → ProjectToNdc` (back to HEAD). | Restored (HEAD converged) | **Loses** the rewire's near-doorway fix → reintroduces the flap/void (separate bug) | Throws away this session's projection work; contradicts the keep-the-dirty-tree directive | + +A bound (paths 1/2) is the sound fix: it makes termination independent of clip drift, so the faithful +`ProjectToClip` projection AND guaranteed termination coexist. **Recommendation: path 1** (enqueue-once + +keep A), visual-verify for under-inclusion. Reverting (path 3) only trades the hang back for the +flap/void. diff --git a/docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md b/docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md new file mode 100644 index 00000000..02d9e4f1 --- /dev/null +++ b/docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md @@ -0,0 +1,589 @@ +# Handoff - M1.5 Indoor Render / Retail PView Replacement Attempts - 2026-06-06 + +This is a **stop-and-handoff** note for the next agent. It records what was tried, what changed on disk, what the user still sees, and what evidence should drive the next step. + +The user explicitly stopped this thread after repeated visual regressions. Do **not** continue the same patching loop. Treat all current uncommitted render work as suspect until re-audited against named retail. + +## Worktree And Rules + +- Worktree: `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` +- Branch: `claude/thirsty-goldberg-51bb9b` +- Starting HEAD called out by the user: `8116d10` +- Do **not** branch or create a new worktree. +- Do **not** push without asking. +- Never run `git stash` or `git gc`. +- PowerShell on Windows. +- Launch logs are UTF-16. +- Build before launching. +- Use `apply_patch` for manual edits. +- Do not revert dirty changes unless the user explicitly asks. + +Current child handoff thread created before this file: + +- Child thread id: `019e9d5c-bb34-7fe3-85cc-6b9065b4e882` +- It was forked same-directory, not a new worktree. +- A follow-up prompt was sent there with the immediate evidence and constraints. + +## User-Visible State At Stop + +The latest user report, after the most recent relaunch: + +- Transition flaps still happen between outdoor/indoor, room/room, and cellar. +- Ground floor became transparent instead of sealed. +- Cellar remains broken. +- Prior screenshots showed grey or black background filling cell openings. +- Prior screenshots showed indoor walls losing texture or drawing as clear/background color. +- Prior screenshots showed character cut in half on the cellar stairs. +- User explicitly says we are back to old bugs and nothing feels solid. + +Important: **do not claim any current code is fixed**. Build/tests passed for some pieces, but visual acceptance failed. + +## Current Dirty State + +`git status --short --branch` showed these tracked files modified: + +- `src/AcDream.App/Rendering/ClipFrameAssembler.cs` +- `src/AcDream.App/Rendering/ClipPlaneSet.cs` +- `src/AcDream.App/Rendering/GameWindow.cs` +- `src/AcDream.App/Rendering/InteriorEntityPartition.cs` +- `src/AcDream.App/Rendering/InteriorRenderer.cs` +- `src/AcDream.App/Rendering/ParticleRenderer.cs` +- `src/AcDream.App/Rendering/PortalView.cs` +- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` +- `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` +- `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` +- `src/AcDream.Core/World/WorldEntity.cs` +- `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs` +- `tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs` +- `tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs` +- `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` +- `tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs` +- `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` +- `tools/TextureDump/Program.cs` + +Important untracked files include: + +- `src/AcDream.App/Rendering/RetailPViewRenderer.cs` +- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` +- many probe logs and local scripts/images, including `launch-flap-shell-capture-relaunch.log`, `launch-pview-watermark-probe.log`, `a8-current-room-cellar-audit.txt`, `texture-current-room-surfaces.txt`, `analyze_*.py`, `retail-*-trace.log`, and several screenshots. + +Diff size before this handoff file: + +- 19 tracked files changed. +- About 1593 insertions and 773 deletions. + +## Validation That Passed But Did Not Prove Visual Correctness + +After the last attempted PortalVisibilityBuilder patch: + +```powershell +dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~PortalVisibilityBuilderTests|FullyQualifiedName~PortalProjectionTests" +``` + +Passed: 29/29. + +```powershell +dotnet build -c Debug --no-restore +``` + +Succeeded, with 9 known warnings. + +```powershell +dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build +``` + +Passed: 196/196. + +These results only prove the pure/tested slices compile and pass. They did **not** solve the live render. + +## Retail PView Reference Already Written + +New pseudocode note exists: + +- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` + +It summarizes: + +- `SmartBox::RenderNormalMode @ 0x00453aa0` +- `RenderDeviceD3D::DrawInside @ 0x0059f0d0` +- `PView::DrawInside @ 0x005a5860` +- `PView::ConstructView @ 0x005a57b0` +- `PView::DrawCells @ 0x005a4840` +- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760` + +Core retail model from that note: + +- Outdoor: `LScape::draw`, then portal/interior peering through PView portal paths. +- Indoor: `DrawInside(viewer_cell)`. +- `PView::ConstructView` builds `cell_draw_list`, per-cell `portal_view`, and `outside_view`. +- `PView::DrawCells` draws outside landscape through `outside_view`, then reverse `cell_draw_list` exit masks, reverse shells, reverse object lists. +- No global indoor terrain/entity/particle pass should bypass PView membership. + +## Retail Functions That Still Matter + +Re-read named retail before more code: + +- `PView::AddViewToPortals @ 0x005a52d0` +- `PView::ConstructView @ 0x005a57b0` +- `PView::ClipPortals @ 0x005a5520` +- `PView::FixCellList @ 0x005a5250` +- `PView::AdjustCellView @ 0x005a5770` +- `PView::OtherPortalClip @ 0x005a5400` +- `PView::GetClip` around `0x005a4320` +- `SmartBox::RenderNormalMode @ 0x00453aa0` +- `SmartBox::update_viewer @ 0x00453ce0` +- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760` + +The critical retail detail not faithfully settled yet: + +- Retail tracks `view_count` and `update_count`. +- When a cell view grows after the cell was already processed, retail calls `FixCellList` / `AdjustCellView`. +- Current acdream code only approximates this. It may not match draw-list ordering or downstream propagation. + +## What We Tried + +### 1. Treated symptoms as separate render leaks + +The session started with symptoms that looked separate: + +- dynamic objects and particles visible through ground when looking out from inside; +- outside ground texture covering cellar entrance when looking in; +- grey flaps when crossing cell boundaries; +- missing cellar floor / grey cellar; +- transparent or textureless interior walls. + +The user correctly pushed back that these are probably one render-pipeline failure: indoor/outdoor, cells, shells, terrain, objects, particles, and doors must all agree on one visible-cell graph. + +### 2. Gated dynamic objects and particles by ownership + +Attempt: + +- `WorldEntity.ParentCellId` was populated for player/spawns/teleports/motion updates. +- `InteriorEntityPartition` was changed so live dynamic entities with an indoor `ParentCellId` go into their cell bucket instead of a global live-dynamic overlay. +- `WbDrawDispatcher.ResolveEntitySlot` was changed so `ServerGuid != 0` no longer always means "draw unclipped indoors". +- Particles were moved toward PView-scoped / owner-scoped behavior instead of a global indoor scene pass. + +Effect: + +- User reported this stopped much of the obvious dynamic-object/particle bleeding when looking out. +- It did **not** fix grey/background transition flaps. +- It did **not** fix cellar/floor/walls. + +Current risk: + +- This direction is probably correct, but the exact routing must be audited. A later attempt also cleared clip routing to avoid character/shell cutting, so "PView membership" and "GPU clip slot routing" are currently mixed/confused. + +### 3. Added/used a `RetailPViewRenderer` + +Attempt: + +- Added `src/AcDream.App/Rendering/RetailPViewRenderer.cs`. +- Moved part of indoor draw orchestration into `RetailPViewRenderer.DrawInside`. +- Added `DrawPortal` for outdoor-looking-in through `PortalVisibilityBuilder.BuildFromExterior`. +- The renderer currently does: + - `PortalVisibilityBuilder.Build` + - `ClipFrameAssembler.Assemble` + - `_envCells.PrepareRenderBatches(filter: drawableCells)` + - `InteriorEntityPartition.Partition` + - landscape through outside slices + - exit masks + - EnvCell shells + - object buckets + +Effect: + +- This is not a full retail replacement yet. +- User repeatedly saw unchanged or worse symptoms. +- FPS was reported drastically down after one iteration. +- Subsequent attempts produced missing textures / white or grey wall panels. + +Current risk: + +- `RetailPViewRenderer` is not truly verbatim retail. It keeps modern infrastructure and approximates PView with GPU clip slots and callbacks. +- The user asked "have you ported retail verbatim?" and the honest answer remains no. +- `GameWindow` still has a lot of orchestration, diagnostics, and render routing around this. It is not a small caller yet. + +### 4. Reworked `ClipFrameAssembler` from one clip per cell to per-slice clip slots + +Attempt: + +- `ClipFrameAssembler` was rewritten toward per-polygon/slice output: + - `CellIdToViewSlices` + - `OutsideViewSlices` + - per-slice `ClipViewSlice` + - `TerrainClipMode` for outside-view landscape +- The goal was to represent retail `portal_view` slices more closely. + +Effect: + +- The code is plausible as a draw assist, but it is not retail membership. +- User saw regressions including black covers during transitions. + +Current risk: + +- The next agent must ensure `ClipFrameAssembler` never decides PView membership. +- It should be draw-assist only. +- Several failures looked like GPU clip slots cutting shells or characters at door/stair boundaries. + +### 5. Disabled clip routing for shells/entities to stop character/stair cutting + +Attempt: + +- `RetailPViewRenderer.UseIndoorMembershipOnlyRouting` clears `_envCells.SetClipRouting(null)` and `_entities.ClearClipRouting()`. +- Comment says retail portal views decide eligibility, but feeding those 2D views into GL clip distances slices characters and shells at stair/door boundaries. + +Effect: + +- This was a reaction to user screenshots where the character was cut in half on stairs. +- It may reduce character slicing. +- It may also mean shells/objects are currently only membership-gated, not portal-view clipped. + +Current risk: + +- This is not a settled retail copy. It is an emergency compromise. +- Retail does use per-view setup (`CEnvCell::setup_view`) around shell/object drawing. We need to know whether our GL clip-plane model is simply the wrong mechanism for that setup. + +### 6. Tried EnvCell / DAT polygon side handling changes + +Attempt: + +- `ObjectMeshManager` changed CellStruct polygon side handling: + - DAT `CullMode` interpreted as retail `CPolygon::sides_type`. + - `0 = pos` + - `1 = pos twice with reversed winding` + - `2 = pos + neg surface` + - `NoPos` / `NoNeg` still suppress faces. +- Added explicit normal inversion / winding reversal logic. + +Effect: + +- User saw missing textures/white/grey interior panels after some launches. +- The attempt did not fix the cellar or transition flaps. + +Current risk: + +- This may be correct retail interpretation or may be partially wrong. +- Audit with DAT dumps and retail/ACME references before keeping. +- `a8-current-room-cellar-audit.txt` and `texture-current-room-surfaces.txt` may contain useful surface/cell evidence. + +### 7. Tried outside-looking-in via `BuildFromExterior` + +Attempt: + +- `PortalVisibilityBuilder.BuildFromExterior` seeds interior cell views through outside-facing exit portals. +- `RetailPViewRenderer.DrawPortal` calls it from outdoor branch. +- Tests were added: + - seeds interior cell through outside portal; + - does not seed when camera is on interior side; + - traverses deeper interior portals; + - max seed distance skips distant exit portal. + +Effect: + +- User initially reported walls became visible looking in from outside, but ground/cellar entrance composition stayed wrong. +- Later launches regressed to transparent/grey panels and missing textures. + +Current risk: + +- This is probably needed, but the exterior portal path is not proven retail-faithful. +- `BuildFromExterior` may now have duplicated-looking test diff context; inspect file carefully. + +### 8. Tried broad "no hybrid" render routing in `GameWindow` + +Attempt: + +- `GameWindow` was changed so indoor path should call `RetailPViewRenderer.DrawInside`. +- Outdoor path should draw world and call `DrawPortal`. +- Global indoor terrain/entity/particle passes were reduced or bypassed. +- New render signature diagnostics log: + - `branch` + - `root` + - `viewerRoot` + - `playerRoot` + - `viewerCell` + - `playerCell` + - `gate` + - `terrain` + - `skyGate` + - `zclear` + - `sceneParticles` + - `outSlices` + - `outPolys` + - `ids` + - `draw` + - object partition counts + +Effect: + +- User explicitly asked whether the hybrid was totally gone. +- It is not safe to answer "yes" without auditing `GameWindow`. +- Symptoms persisted, so either the routing is still hybrid or the PView graph/draw setup is wrong enough that "no hybrid" alone does not solve it. + +Current risk: + +- `GameWindow.cs` has a very large diff, around 1000 lines touched. +- Next agent should not blindly keep it. +- Audit all remaining global passes while `clipRoot != null`. + +### 9. Tried PView `update_count`-style reprocessing + +Attempt in the last aborted step: + +- `PortalView.CellView.Add` now returns `bool` and deduplicates near-identical polygons. +- `PortalVisibilityBuilder.Build` replaced `seen` with: + - `queued` + - `drawListed` + - `processedViewCounts` +- A cell can be requeued when its view grows. +- Each processing pass clips portals against only newly added view polygons. +- Similar logic was added to `BuildFromExterior`. +- Added tests: + - `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour` + - `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` + +Effect: + +- Focused tests passed. +- Live probe after patch still showed `outPolys` toggling near root `0xA9B40172`. +- User then reported transition flaps still there and now ground floor transparent. + +Current risk: + +- This patch is **unaccepted** and may be wrong. +- It approximates retail `update_count`, but does not necessarily implement `FixCellList`, `AdjustCellPlace`, or retail draw-list ordering correctly. + +### 10. Widened eye-standing-in-portal fallback + +Attempt: + +- `EyeStandingPerpDist` widened from `0.5f` to `1.75f`. +- Motivation: live cellar capture had `0174 -> 0175` traversable with `D=-1.41` but `ProjectToNdc` returned zero vertices. +- The fallback still requires the perpendicular projection to land inside the portal opening. + +Effect: + +- It made one unit test pass for the cellar-style collapsed portal. +- It did not solve live transitions. + +Current risk: + +- This may be a bandaid, not retail. +- It should be validated against `OtherPortalClip` / `GetClip` in named retail before keeping. + +### 11. Tried reciprocal clip fallback for eye-in-opening + +Attempt: + +- Before `ApplyReciprocalClip`, code clones `clippedRegion` when `eyeInsideOpening`. +- If reciprocal clipping empties the region, it restores the pre-reciprocal region. + +Effect: + +- Tests passed. +- Live visual did not. + +Current risk: + +- This may over-include. +- It is not proven retail-faithful. + +## Critical Evidence From Logs + +### Cellar startup: root 0174 only sees itself + +From `launch-flap-shell-capture-relaunch.log`: + +```text +[flap] root=0xA9B40174 eye=(154.50,4.99,92.25) localEye=(7.43,2.51,-1.77) | +p0->0x0175 D=-1.41 TRV proj=0 clip=-1 || outPolys=0 vis=1 + +[flap-cam] root=0xA9B40174 viewerCell=0xA9B40174 playerCell=0xA9B40174 +... terrain=Skip outVisible=False + +[render-sig] frame=49 branch=RetailPViewInside root=0xA9B40174 +... terrain=Skip/skip sky=n zclear=n sceneParticles=none +outSlices=0 outPolys=0 ids=[0xA9B40174] draw=[0xA9B40174] +``` + +Meaning: + +- The player/viewer/root are in cellar cell `0174`. +- The only visible portal to stair connector `0175` is traversable. +- Projection produces zero vertices. +- The PView flood stops at the cellar. +- Only the cellar draws; stair/main-floor cells are not in the visible set. + +This is a direct candidate cause for missing floor/grey composition. + +### Root 0172: outside view toggles on/off + +From `launch-pview-watermark-probe.log`: + +```text +frame=3625 root=0xA9B40172 +p0->0x0173 D=... TRV proj=4 clip=4 +p1->0x016F D=5.28 TRV proj=0 clip=-1 +outPolys=1 vis=6 +ids include 0xA9B40170 +terrain=Skip/draw sky=Y zclear=Y + +frame=3626 root=0xA9B40172 +p0->0x0173 D=... TRV proj=5 clip=5 +p1->0x016F D=5.39 TRV proj=0 clip=-1 +outPolys=0 vis=5 +ids missing 0xA9B40170 +terrain=Skip/skip sky=n zclear=n + +frame=3647 root=0xA9B40172 +outPolys=1 ids include 0xA9B40170 terrain draw + +frame=3648/3649 root=0xA9B40172 +outPolys=0 ids missing 0xA9B40170 terrain skip +``` + +Meaning: + +- The same root cell can alternate between seeing outside and not seeing outside. +- `0x016F` is involved in the root flap line but projects to zero. +- Sometimes `0x0170` becomes reachable and outside terrain/sky/depth clear run; sometimes it disappears. +- The visible-cell list and outside-view list are not stable. + +Open question: + +- Is `0x016F` an outdoor/land cell, an env cell lookup miss, or a portal that retail handles differently? +- Is the toggling caused by projection/clip degeneracy, wrong portal reciprocal handling, update-count propagation, or camera/viewer-cell root? + +### Earlier known evidence: root 0171 vs player 0174 contradiction + +From the older `2026-06-05-shell-sealing-cellar-floor-handoff.md`: + +```text +[flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174 +... +[flap] root=0xA9B40171 ... p1->0173 proj=0 ... +``` + +Meaning: + +- Earlier, camera/root was the room while player was cellar. +- The flood did not seal the player's cell. +- Later, after branch/viewer changes, there are also frames where root/player/viewer are all `0174` but the flood still fails on `0174 -> 0175`. + +This means the problem is probably not only "wrong root"; it also includes projection/portal traversal/flood propagation or mesh-shell handling. + +## What Not To Retry Blindly + +Do not simply: + +- switch the root to player cell as a workaround; +- widen `EyeStandingPerpDist` further; +- globally draw all indoor shells; +- globally draw terrain/entities/particles while inside; +- turn off all clipping and hope depth sorts it; +- keep adding `if cellar` or Holtburg-cottage-specific handling; +- claim "no hybrid" without auditing all `GameWindow` indoor/outdoor passes; +- equate unit-test pass with visual correctness. + +The user has explicitly asked for retail smoothness, not a new patch stack. + +## Likely Root Problem Space + +The next fix probably lives in one of these, but evidence must decide: + +1. **PView graph construction is not retail-faithful.** + - Missing or wrong `update_count` / `FixCellList` / `AdjustCellView`. + - Wrong draw-list ordering when a processed cell receives new views. + - Downstream portal propagation incomplete. + +2. **Portal projection/clip behavior differs from retail.** + - `0174 -> 0175` traversable but `proj=0`. + - `0172 -> 016F` traversable but `proj=0`. + - `OtherPortalClip` / `GetClip` may not match retail. + +3. **Outdoor/exit portal classification is wrong.** + - `OtherCellId=0xFFFF` is treated as exit/outside, but `0x016F` may be another kind of outside/land portal or missing env cell. + - OutsideView may be created through a downstream path that acdream sometimes drops. + +4. **Renderer draw setup is still hybrid or ordered wrong.** + - `GameWindow` may still draw or skip global passes inconsistently. + - Sky/terrain/depth clear decisions are visibly flapping with `outside_view`. + +5. **EnvCell shell mesh/surface handling is wrong.** + - Missing/transparent/white walls and floors may be mesh/surface/cull-side regressions. + - Audit `ObjectMeshManager` side handling against retail and DAT dumps. + +6. **GPU clip slots are being used as membership or hard clipping when retail uses view setup differently.** + - Character cut in half on stairs strongly suggests hard clip-plane use on avatars/shells is wrong or applied at wrong pass. + +## Suggested Next Procedure + +1. Stop patching. Inspect the dirty diff first. +2. Read `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`. +3. Re-read named retail functions listed above. +4. Parse `launch-flap-shell-capture-relaunch.log` and `launch-pview-watermark-probe.log` around the cited frames. +5. Add better probes if needed: + - cell id; + - portal index; + - other cell id; + - portal flags; + - other portal id; + - traversable decision; + - standing distance; + - projection vertex count; + - clip vertex count; + - reciprocal clip result; + - outside-view add/skip reason; + - cell view count / processed count / update count; + - queue/requeue reason; + - draw-list insertion/reorder. +6. Decide from evidence whether `0174 -> 0175` and `0172 -> 016F` fail because of projection, reciprocal clip, cell lookup/classification, or update propagation. +7. Patch only the retail mismatch. +8. Build/test before launch. +9. Launch with probes once. +10. Then launch clean for FPS/visual feel. +11. Do not call it done until the user visually confirms retail smoothness. + +## Launch Command + +Use PowerShell: + +```powershell +Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 3 + +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_PROBE_FLAP = "1" +$env:ACDREAM_PROBE_SHELL = "1" +$env:ACDREAM_PROBE_VIS = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "launch-next-pview.log" +``` + +For clean visual/FPS run, remove the probe env vars. + +## Minimal Prompt For Next Agent + +```text +Continue acdream M1.5 indoor render in SAME worktree: +C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b +branch claude/thirsty-goldberg-51bb9b. Do NOT branch/worktree. Do NOT push. NEVER stash/gc. + +The current dirty render code is not visually accepted. The user stopped the prior agent after repeated regressions. +Read docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md first. + +Current symptoms: transition flaps still happen indoor/outdoor, room/room, cellar; ground floor is now transparent; cellar broken; prior runs showed grey/black clear color, missing wall textures, and character cut on stairs. + +Do not patch first. Audit dirty diff, read named retail PView, parse launch-flap-shell-capture-relaunch.log and launch-pview-watermark-probe.log. Determine exactly why: +1) cellar root 0174 fails to traverse 0174 -> 0175 when proj=0; +2) root 0172 toggles outside_view/0170 reachability while 0172 -> 016F has proj=0; +3) shell/object/terrain/depth-clear decisions disagree. + +Patch only the retail mismatch. Build/test before relaunch. Do not claim success before user visual confirmation. +``` + diff --git a/docs/research/2026-06-07-indoor-render-session-handoff.md b/docs/research/2026-06-07-indoor-render-session-handoff.md new file mode 100644 index 00000000..628e7d39 --- /dev/null +++ b/docs/research/2026-06-07-indoor-render-session-handoff.md @@ -0,0 +1,160 @@ +# Indoor Render — Session Handoff: HANG fixed + interior SEALS; the FLAP is next — 2026-06-07 + +> Worktree `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; +> launch logs UTF-16; build before launch; acceptance is the user's eyes. Live ACE `127.0.0.1:9000`, +> `testaccount`/`testpassword`, char `+Acdream` (spawns near the Holtburg / "Arcanum" cottage — +> landblock `0xA9B4`, cottage cells `0xA9B4016F–0175`). Do NOT branch/worktree, push, or `git stash`/`gc`. + +## TL;DR + +The two-week indoor-render **HANG is FIXED** and the interior **SEALS** (walls/floor/ceiling draw, +textured) — both committed this session and live-verified by the user ("Ok now it runs!"). A +structured live test pinned the remaining dominant visible issue, the **FLAP at transitions**, as +**viewer-cell metastability**: the render roots at the camera-eye cell, which oscillates +outdoor↔indoor as the 3rd-person boom drifts across the doorway plane. **The flap is a SEPARATE, +already-designed fix — it is NOT the verbatim DrawCells port; finishing the port will not fix it.** +Next session: **fix the flap** (camera-boom stability + viewer-cell dead-zone). Tracked follow-ups: +#78 terrain gating, look-in-from-inside sealing, look-in FPS, L-spotlight. + +## What shipped this session (committed — see `git log` on this branch) + +### 1. The HANG fix (the blocker) +Indoor frames froze (`AppHangB1`; not a crash — captured the spinning managed stack via a +`dotnet-stack` hang-watcher). Root cause: `PortalVisibilityBuilder.Build`'s portal-visibility flood +**did not terminate** for real cottage geometry. Two layers, two fixes (both kept): +- **A — drift-tolerant `CellView.Add` dedup** (`src/AcDream.App/Rendering/PortalView.cs`). The flood + re-queues a cell every time its view GROWS; growth only stops when the dedup recognises a re-clipped + region as a duplicate. The faithful `ProjectToClip` near-side clip drifts per round, so the old + exact index-by-index match (eps 1e-4) never caught the near-duplicate → unbounded growth → O(n²) + CPU-spin in `CellView.Add`. Fix: key each polygon by its vertices **snapped to a 1e-3 NDC grid**, + consecutive-dedup'd, **canonically rotated** to a lex-min start → finite key space → convergence. + Tests: `tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs` (3). +- **B — bounded re-enqueue** (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`). A alone did not + fully converge (the spin relocated to `ScreenPolygonClip.ClipByEdge` — bounded loops — inside the + still-non-terminating BFS). Restored the **`MaxReprocessPerCell = 16`** hard cap that Phase U.2a + deleted ("fixpoint termination" left the loop with NO bound). **Kept the re-enqueue** — it is + load-bearing for late-slice propagation (`Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`). + Pure enqueue-once was tried and **broke that test**, so re-enqueue is kept and merely bounded. +- Deep diagnosis + the reassessment that led to B: `docs/research/2026-06-06-indoor-render-hang-rootcause.md`. +- **Verified:** clean exit (255→0); runs indoors with no freeze; the indoor flood converges in ~1 + round/cell at normal positions (measured 3–5 pops/frame, 1 view-poly/cell). The cap only bites at + the metastable doorway. + +### 2. The SEAL (verbatim DrawCells port — Task 2) +`RetailPViewRenderer.DrawEnvCellShells` now iterates `IndoorDrawPlan.ShellPass(pvFrame)` — **every** +visible cell's shell draws (was gated on `ClipFrameAssembler`'s slot filter → cells without a slot +were silently dropped → grey clear-color void). Verified: interior seals + textured. (Task 1 +`IndoorDrawPlan` + its test committed earlier as `bff1955`.) + +### 3. Look-in FPS +`GameWindow` exterior-look-in candidate cells limited to the player's landblock **±1** (was **all +~81 loaded landblocks** iterated every outdoor frame just to discard them via the 48 m seed cutoff). +Provably no behavior change (excluded cells are >48 m, already culled). Outdoor FPS improved but +still **~110 fps / ~9 ms (was ~200)** — `DrawPortal` still draws ~12 building interiors/frame (see +follow-up). + +## Baselines (must hold at next session start) +- `dotnet build -c Debug` **0 errors**. +- App.Tests **210/210** (205 baseline + IndoorDrawPlanTests 2 + CellViewDedupTests 3). +- Core.Tests **1331 pass / 4 fail / 1 skip** — the 4 are pre-existing Physics door/step-up, unrelated. + +## Structured live test — findings (Holtburg/Arcanum cottage, 2026-06-07) +User walked a 6-step protocol (inside-still → camera-pan → doorway-threshold → just-outside → +looking-at-cottage → cellar) and reported 8 behaviours; `ACDREAM_PROBE_FLAP` `[render-sig]` +correlated each. + +| # | Observed | Cause | Bucket | +|---|---|---|---| +| 2,3,6,8 | walls briefly transparent / window+entrance "covered by the world background" / abrupt "teleport" through the doorway — all **at transitions (camera crossing a threshold)** | **THE FLAP** | viewer-cell stability (NEXT) | +| 1 | outdoor grass covers the cellar-entrance hole (steady, looking in from outside) | outdoor terrain not gated over the indoor floor opening | **#78** terrain gating | +| 7 | from inside, a building seen through the doorway has transparent walls (world-bg shows); pops back when you step outside | look-out shows other buildings unsealed | look-in/look-out completeness | +| 5 | spotlight blobs on textures from the ceiling lamp (always been there) | point-light artifact | **L-spotlight** (separate) | +| FPS | inside very high; outside **110 fps / ~9 ms** (was ~200) | `DrawPortal` draws ~12 interiors/frame | look-in cost | +| 4 | cellar transitions **stable** ✓ | vertical transition doesn't cross the outdoor boundary | — | + +### The FLAP — pinned (render-sig evidence) +`[render-sig]` over the doorway shows the render branch + the cell it roots at flip-flopping while the +**player cell stays inside**: +``` +50× branch=OutdoorRoot viewer=0xA9B40031 (outdoor) player=0xA9B40171 (indoor) gate=in +16× branch=RetailPViewInside viewer=0xA9B40170 (indoor) player=0xA9B40171 gate=in +113× branch=RetailPViewInside viewer=0xA9B40171 (indoor) player=0xA9B40171 gate=in + ... oscillates 0x0031 ↔ 0x0170 ↔ 0x0171 frame-to-frame ... +``` +**Mechanism:** the render roots at the **viewer (camera-eye) cell** (`clipRoot = viewerRoot`, Phase W +"one viewpoint"). The 3rd-person boom drifts the eye across the doorway plane; acdream re-resolves the +viewer cell fresh each frame with **no hysteresis** → it flips between outdoor `0x0031` and indoor +`0x0170/0x0171` → the render flips `OutdoorRoot`↔`RetailPViewInside` → the indoor seal drops (walls +transparent, outdoor world/grass shows) then re-seals → **flapping**. This is exactly the 2026-06-05 +viewer-cell-flicker diagnosis, now confirmed against the live render branch. + +## RECOMMENDED NEXT WORK — fix the FLAP (separate, already-designed) +Per `docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`, 3 retail-faithful parts: +1. **Viewer-cell dead-zone (do this first)** — ±0.2 mm cell hysteresis so a sub-mm eye drift can't flip + the cell (`PhysicsCameraCollisionProbe.SweepEye`; retail `point_inside_cell_bsp` 0x53c1f0). Highest + leverage — likely kills most of the flap on its own. +2. **Camera-boom stability** — stop the boom drifting at rest (`RetailChaseCamera.UpdateCamera`; retail + `UpdateCamera` 0x456660). +3. **w-space (w=0) portal clip** — close-portal projection degeneracy (`PortalProjection` / + `PortalVisibilityBuilder`; retail `GetClip` 0x5a4320 / `polyClipFinish` 0x6b6d00). Lower priority. + +Apparatus ready: `ACDREAM_PROBE_FLAP` emits `[render-sig]` (branch/viewer/player/gate per frame), +`[flap]`, `[flap-cam]`, `[flap-sweep]` — light enough to launch with (the heavy `ACDREAM_PROBE_SHELL` +firehose is what previously caused an I/O stall; avoid it). + +## Tracked follow-ups (logged; not yet fixed) +- **#78 terrain gating** — outdoor terrain (grass) draws over the indoor cellar-entrance hole (and likely + other indoor floors). Decomp anchor `CEnvCell::find_visible_child_cell` (`acclient_2013_pseudo_c.txt:311397`). +- **Look-in-from-inside** — buildings seen through your door/window from inside render unsealed + (transparent walls); the look-out pass doesn't draw other buildings' shells. DrawCells port Task 5/7 + territory (or R2 "outside-looking-in"). +- **Look-in FPS** — `DrawPortal` draws ~12 building interiors every outdoor frame (~110 vs ~200 fps). + Optimize: only look into buildings whose exit portals are frustum-visible; skip when no door is in view. +- **L-spotlight** — ceiling-lamp point light makes spotlight blobs on textures. Pre-existing, separate. + +## verbatim DrawCells port — remaining tasks (deferred) +Plan: `docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md`. Task 1 + Task 2 done. +**Task 3** (objects no-clip) is effectively already satisfied (objects draw membership-gated with no +clip; no half-characters observed). **Tasks 4–8** (per-slice trim, look-out, delete `ClipFrameAssembler`, +look-in, final) are **cleanup with no current visible payoff** — the seal works and there is **no visible +bleed** (the "glitches between cells" were the FLAP, not bleed). **Task 4 (trim) is intricate** (its +per-slice `_clipFrame.Reset()` is coupled with the landscape/particle passes that still read +`clipAssembly` slots) and **risks re-slicing the working seal** — do it carefully, fresh, and only when +clean architecture is the priority. + +## DO NOT re-litigate +- The HANG fix (A drift-dedup + B bounded re-enqueue) is correct + verified. **Do NOT try pure + enqueue-once** — it breaks `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` (late-slice + propagation needs the re-enqueue; the cap, not removal, is the termination guarantee). +- The grey was the `drawableCells` / `ClipFrameAssembler` slot filter; Task 2 fixed it. The clip math is + faithful — do not "harden the w-clip". +- **The FLAP is NOT the DrawCells port.** It is viewer-cell metastability (camera/membership). Tasks 4–8 + will NOT fix it. +- The render roots at the VIEWER (camera-eye) cell intentionally (Phase W "one viewpoint"). The flap fix + is to STABILISE the viewer cell (dead-zone + boom), NOT to re-root at the player cell (superseded). + +## Copy-paste pickup prompt (next session) +``` +Pick up the indoor-render work in worktree thirsty-goldberg-51bb9b (branch +claude/thirsty-goldberg-51bb9b). PowerShell; launch logs UTF-16; build before launch; acceptance is +the user's eyes. Do NOT branch/worktree, push, git stash/gc, or revert the dirty tree. + +Read first: docs/research/2026-06-07-indoor-render-session-handoff.md (state, what shipped, the FLAP +diagnosis, do-not-relitigate). Then docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md +(the flap fix plan). + +Confirm baselines: build 0 errors; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. + +The indoor HANG is fixed and the interior SEALS (shipped + committed last session). The remaining +dominant visible issue is the FLAP at transitions — viewer-cell metastability: the render roots at the +camera-eye cell, which oscillates outdoor↔indoor as the 3rd-person boom drifts across the doorway (no +hysteresis), confirmed in [render-sig]. FIX THE FLAP, starting with the viewer-cell dead-zone +(PhysicsCameraCollisionProbe.SweepEye; retail point_inside_cell_bsp 0x53c1f0), then camera-boom +stability (RetailChaseCamera.UpdateCamera; retail UpdateCamera 0x456660). Launch with ACDREAM_PROBE_FLAP +only (NOT ACDREAM_PROBE_SHELL — it stalls on I/O). Gate on the user's eyes at the cottage doorway. + +Do NOT: retry pure enqueue-once (breaks late-slice propagation); re-root render at the player cell +(viewer-cell rooting is intentional); finish DrawCells port Tasks 4-8 expecting it to fix the flap (it +won't). Tracked follow-ups (not the flap): #78 terrain gating (grass over cellar hole), look-in-from- +inside sealing, look-in FPS (DrawPortal ~12 interiors/frame), L-spotlight. +``` diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index cff57a00..3545bee7 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -1,251 +1,224 @@ // ClipFrameAssembler.cs // -// Phase U.4: assemble a per-frame ClipFrame (the GPU-side shared clip data) + -// a cellId→slot map from a PortalVisibilityFrame. This is the CPU policy that -// turns the portal-visibility BFS result into the slot indices the mesh shader -// (binding=2 CellClip + binding=3 per-instance slot) and the terrain UBO read. +// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like +// view graph: one portal_view list per visible cell plus an outside_view list. +// This assembler packs each visible polygon as an individual GPU clip slot so +// the renderer can draw the exact PView order: // -// GL-free: ClipFrame's CPU byte-packing (AppendSlot / SetTerrainClip) runs here; -// the GL upload (ClipFrame.UploadShared) happens at the call site. That keeps the -// whole slot/gate policy unit-testable without a GPU context — see -// ClipFrameAssemblerTests. +// outside_view landscape slices +// reverse cell_draw_list exit masks +// reverse cell_draw_list EnvCell shells +// reverse cell_draw_list object lists // -// === The slot/gate policy (implemented EXACTLY as the U.4 spec dictates) ====== -// slot 0 = no-clip (count 0). ALWAYS present (ClipFrame.NoClip seeds it). -// -// Per visible interior cell (PortalVisibilityFrame.OrderedVisibleCells): -// ClipPlaneSet.From(CellView) has THREE Count==0 meanings (see ClipPlaneSet): -// • IsNothingVisible ⇒ DO NOT map the cell. Its instances/shell won't draw -// (the cull is deliberate — retail culls it too). -// • Count > 0 ⇒ append a real planes slot; cellIdToSlot[cell] = slot. -// • UseScissorFallback⇒ cellIdToSlot[cell] = 0 (no-clip / over-include). -// Per-cell glScissor would break MDI batching, and -// over-inclusion is the SAFE direction; counted in -// ScissorFallbacks for the probe. -// -// OutsideView feeds TWO consumers: -// • mesh "outdoor slot" (outdoor scenery / building shells drawn while the -// camera is indoors): Count>0 ⇒ planes slot (OutdoorSlot); scissor ⇒ slot 0 -// (no-clip, counted); IsNothingVisible ⇒ OutdoorVisible=false (CULL these -// instances — the camera can't see outdoors through any portal chain). -// • terrain UBO: Count>0 ⇒ SetTerrainClip(planes); scissor ⇒ TerrainScissor -// (the call site sets glScissor around ONLY the terrain draw) + UBO count 0; -// IsNothingVisible ⇒ SKIP the terrain draw entirely (THIS is the bleed fix). -// -// Outdoor root (pvFrame == null) is handled by the caller, not here: terrain -// draws normally (UBO count 0, no scissor), every instance is slot 0. The caller -// only invokes Assemble when there IS an indoor root. +// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the +// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for +// passes that need that fallback. Empty regions are omitted entirely. using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; /// -/// How the terrain (single OutsideView region) should be drawn this frame. +/// How the landscape-through-outside_view pass should be interpreted. /// public enum TerrainClipMode { - /// OutsideView reduced to convex planes — terrain gated via the UBO - /// ( already applied by the assembler). + /// All outside_view slices have convex plane clips. Planes, - /// OutsideView exceeded the convex budget — the call site sets a - /// glScissor to around ONLY - /// the terrain draw; the UBO is left at count 0 (ungated). + /// At least one outside_view slice requires scissor fallback. Scissor, - /// OutsideView is empty (no exit portal visible through any chain) — - /// the call site SKIPS the terrain draw entirely. This is the bleed fix: an - /// interior with no view outdoors draws no terrain. + /// No outside_view slice is visible; skip landscape indoors. Skip, } /// -/// Result of : the populated -/// (CPU bytes ready; caller does UploadShared) plus -/// the per-instance routing data the renderers + the terrain draw consume. +/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained +/// for passes that cannot write gl_ClipDistance and must use scissor. +/// +public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes); + +/// +/// Result of : populated clip buffers +/// plus routing data consumed by the render orchestration. /// public sealed class ClipFrameAssembly { - /// The per-frame clip data. Caller uploads it via - /// then hands its - /// / to the - /// renderers. public required ClipFrame Frame { get; init; } - /// Maps a visible cell id to its CellClip slot index. A cell that is - /// NOT a key (IsNothingVisible, or never visible) must NOT be drawn — its mesh - /// instances / shell are culled. A scissor-fallback cell maps to slot 0. + /// First drawable slice slot per visible cell. Compatibility map + /// for renderer APIs that can accept only one slot at a time. public required Dictionary CellIdToSlot { get; init; } - /// Slot for outdoor scenery / building-shell instances (ParentCellId - /// == null) while the camera is indoors. Meaningful only when - /// is true. 0 ⇒ no-clip (scissor fallback or trivial). + /// Slot-only cell slices, retained for older renderer APIs. + public required Dictionary CellIdToViewSlots { get; init; } + + /// Full retail portal_view slices per visible cell. + public required Dictionary CellIdToViewSlices { get; init; } + + /// Full retail outside_view slices. + public required ClipViewSlice[] OutsideViewSlices { get; init; } + public required int OutdoorSlot { get; init; } - - /// False ⇒ the OutsideView is empty; outdoor scenery / shells are - /// CULLED this frame (camera sees no outdoors through any portal chain). public required bool OutdoorVisible { get; init; } - - /// How to draw terrain (planes already applied to the UBO / scissor / - /// skip). See . public required TerrainClipMode TerrainMode { get; init; } - - /// NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when - /// is . Unused otherwise. public required Vector4 TerrainScissorNdcAabb { get; init; } - - /// True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this - /// frame — the camera can see outdoors through a portal chain ( is - /// or ). False ⇒ a - /// sealed interior with no exit portal in view (). Drives the - /// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root - /// (the caller does not invoke there). public required bool HasOutsideView { get; init; } - - /// NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway - /// opening's bounding box. Computed whenever is true, for BOTH the - /// Planes and Scissor terrain modes (unlike , which is valid - /// only in Scissor mode). Stage 4 scissors the conditional doorway depth-only Z-clear (retail - /// PView::DrawCells:432731) and the sky/weather particle passes to this region. Degenerate - /// () when is false. public required Vector4 OutsideViewNdcAabb { get; init; } - // ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) -------- - - /// Plane count the OutsideView reduced to (0 ⇒ scissor or empty). + // Probe data. public required int OutsidePlaneCount { get; init; } - - /// Per-cell clip-plane count (cell id → plane count) for the probe. - /// A scissor-fallback cell records 0 here (it maps to slot 0). public required Dictionary PerCellPlaneCounts { get; init; } - - /// Number of regions (cells + OutsideView) that fell back to a scissor - /// AABB → no-clip this frame. public required int ScissorFallbacks { get; init; } } -/// -/// Builds a from a . -/// Pure CPU; no GL. The single entry point implements the U.4 -/// slot/gate policy (file header). -/// public static class ClipFrameAssembler { - /// - /// Assemble the per-frame clip data + routing from a portal-visibility frame - /// INTO an existing — the long-lived GameWindow frame is - /// -and-repacked here every frame so its GL buffers - /// are reused (no per-frame buffer churn). The returned assembly's - /// is the same instance passed in. - /// public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame) { System.ArgumentNullException.ThrowIfNull(frame); System.ArgumentNullException.ThrowIfNull(pvFrame); - frame.Reset(); // slot 0 = no-clip + frame.Reset(); + var cellIdToSlot = new Dictionary(); + var cellIdToViewSlots = new Dictionary(); + var cellIdToViewSlices = new Dictionary(); var perCellPlaneCounts = new Dictionary(); int scissorFallbacks = 0; - // ── Interior cells ─────────────────────────────────────────────────── foreach (uint cellId in pvFrame.OrderedVisibleCells) { if (!pvFrame.CellViews.TryGetValue(cellId, out var view)) - continue; // defensive — OrderedVisibleCells is derived from CellViews - - var cps = ClipPlaneSet.From(view); - - if (cps.IsNothingVisible) - { - // Cell culled — do NOT map it; its instances/shell won't draw. continue; + + var slices = new List(view.Polygons.Count); + int maxPlaneCount = 0; + + foreach (var poly in view.Polygons) + { + var cps = ClipPlaneSet.From(ViewOf(poly)); + if (cps.IsNothingVisible) + continue; + + int slot; + Vector4[] planes; + if (cps.Count > 0) + { + planes = ToPlaneSpan(cps); + slot = frame.AppendSlot(planes); + if (cps.Count > maxPlaneCount) + maxPlaneCount = cps.Count; + } + else + { + planes = System.Array.Empty(); + slot = 0; + scissorFallbacks++; + } + + slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); } + + if (slices.Count == 0) + continue; + + var sliceArray = slices.ToArray(); + cellIdToViewSlices[cellId] = sliceArray; + cellIdToViewSlots[cellId] = ToSlots(sliceArray); + cellIdToSlot[cellId] = sliceArray[0].Slot; + perCellPlaneCounts[cellId] = maxPlaneCount; + } + + var outsideSlicesList = new List(pvFrame.OutsideView.Polygons.Count); + int outsideMaxPlaneCount = 0; + bool outsideHasScissorFallback = false; + + foreach (var poly in pvFrame.OutsideView.Polygons) + { + var cps = ClipPlaneSet.From(ViewOf(poly)); + if (cps.IsNothingVisible) + continue; + + int slot; + Vector4[] planes; if (cps.Count > 0) { - int slot = frame.AppendSlot(cps); - cellIdToSlot[cellId] = slot; - perCellPlaneCounts[cellId] = cps.Count; + planes = ToPlaneSpan(cps); + slot = frame.AppendSlot(planes); + if (cps.Count > outsideMaxPlaneCount) + outsideMaxPlaneCount = cps.Count; } - else // UseScissorFallback (Count == 0, not nothing-visible) + else { - // Over-include via no-clip (slot 0). Per-cell glScissor would break - // MDI batching; over-inclusion is the safe direction for M1.5. - cellIdToSlot[cellId] = 0; - perCellPlaneCounts[cellId] = 0; + planes = System.Array.Empty(); + slot = 0; + outsideHasScissorFallback = true; scissorFallbacks++; } + + outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); } - // ── OutsideView ────────────────────────────────────────────────────── - var ov = ClipPlaneSet.From(pvFrame.OutsideView); + var outsideViewSlices = outsideSlicesList.ToArray(); + bool outdoorVisible = outsideViewSlices.Length > 0; + int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0; + TerrainClipMode terrainMode = !outdoorVisible + ? TerrainClipMode.Skip + : (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes); - int outdoorSlot; - bool outdoorVisible; - TerrainClipMode terrainMode; - Vector4 terrainScissor = Vector4.Zero; - - if (ov.IsNothingVisible) - { - // No outdoors visible through any portal chain. - outdoorSlot = 0; - outdoorVisible = false; // mesh: CULL outdoor scenery / shells. - terrainMode = TerrainClipMode.Skip; // terrain: the bleed fix. - } - else if (ov.Count > 0) - { - // Convex planes — gate both the outdoor mesh slot and the terrain UBO. - outdoorSlot = frame.AppendSlot(ov); - outdoorVisible = true; - frame.SetTerrainClip(ToPlaneSpan(ov)); - terrainMode = TerrainClipMode.Planes; - } - else // UseScissorFallback - { - // Mesh: no-clip over-include (slot 0), still visible. Terrain: scissor - // around the single terrain batch + UBO ungated (count 0 left as-is). - outdoorSlot = 0; - outdoorVisible = true; - terrainMode = TerrainClipMode.Scissor; - terrainScissor = ov.ScissorNdcAabb; - scissorFallbacks++; - } - - // Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for - // BOTH Planes and Scissor modes — the sky/weather particle scissor + the conditional - // doorway Z-clear need it regardless of how the OutsideView reduced to a gate. - // TerrainScissorNdcAabb above is only valid in Scissor mode; the OutsideView CellView - // always tracks its Min/Max as polygons accumulate, so it is the single source here. - bool hasOutsideView = terrainMode != TerrainClipMode.Skip; - Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty) + Vector4 outsideViewNdcAabb = outdoorVisible ? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY, pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY) : Vector4.Zero; + Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor + ? outsideViewNdcAabb + : Vector4.Zero; return new ClipFrameAssembly { Frame = frame, CellIdToSlot = cellIdToSlot, + CellIdToViewSlots = cellIdToViewSlots, + CellIdToViewSlices = cellIdToViewSlices, + OutsideViewSlices = outsideViewSlices, OutdoorSlot = outdoorSlot, OutdoorVisible = outdoorVisible, TerrainMode = terrainMode, TerrainScissorNdcAabb = terrainScissor, - HasOutsideView = hasOutsideView, + HasOutsideView = outdoorVisible, OutsideViewNdcAabb = outsideViewNdcAabb, - OutsidePlaneCount = ov.Count, + OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0, PerCellPlaneCounts = perCellPlaneCounts, ScissorFallbacks = scissorFallbacks, }; } - // Copy a ClipPlaneSet's planes into a heap array for SetTerrainClip's span - // parameter (the set exposes IReadOnlyList, not a contiguous span). + private static CellView ViewOf(ViewPolygon poly) + { + var view = new CellView(); + view.Add(poly); + return view; + } + + private static Vector4 AabbOf(ViewPolygon poly) => + new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY); + + private static int[] ToSlots(ClipViewSlice[] slices) + { + var slots = new int[slices.Length]; + for (int i = 0; i < slices.Length; i++) + slots[i] = slices[i].Slot; + return slots; + } + private static Vector4[] ToPlaneSpan(ClipPlaneSet set) { int n = set.Count; var planes = new Vector4[n]; - for (int i = 0; i < n; i++) planes[i] = set.Planes[i]; + for (int i = 0; i < n; i++) + planes[i] = set.Planes[i]; return planes; } } diff --git a/src/AcDream.App/Rendering/ClipPlaneSet.cs b/src/AcDream.App/Rendering/ClipPlaneSet.cs index a4824eb7..47d4e2f6 100644 --- a/src/AcDream.App/Rendering/ClipPlaneSet.cs +++ b/src/AcDream.App/Rendering/ClipPlaneSet.cs @@ -66,7 +66,6 @@ public readonly struct ClipPlaneSet // or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far // above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here. private const float MinPolygonArea = 1e-7f; - private readonly Vector4[] _planes; private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 93f1e58f..99527c81 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -170,6 +170,7 @@ public sealed class GameWindow : IDisposable // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer; + private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain @@ -180,6 +181,14 @@ public sealed class GameWindow : IDisposable // three renderers so each re-binds binding=2 immediately before its own draw. // U.4 replaces the NoClip() frame with one built from the portal-visibility result. private ClipFrame? _clipFrame; + private readonly HashSet _outdoorRootNoCells = new(0); + private readonly HashSet _exteriorPortalLandblocks = new(); + private readonly List _exteriorPortalCandidateCells = new(); + private readonly HashSet _outdoorSceneParticleEntityIds = new(); + private readonly HashSet _visibleSceneParticleEntityIds = new(); + private string? _lastRenderSignature; + private int _renderSignatureFrame; + private int _renderSignatureStableFrames; /// /// Phase 6.4: per-entity animation playback state for entities whose @@ -1805,6 +1814,9 @@ public sealed class GameWindow : IDisposable // R1: the per-cell DrawInside flood. Both renderers exist here (just constructed). _interiorRenderer = new AcDream.App.Rendering.InteriorRenderer(_envCellRenderer!, _wbDrawDispatcher!); + _clipFrame ??= ClipFrame.NoClip(); + _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( + _gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -2793,6 +2805,7 @@ public sealed class GameWindow : IDisposable MeshRefs = meshRefs, PaletteOverride = paletteOverride, PartOverrides = entityPartOverrides, + ParentCellId = spawn.Position!.Value.LandblockId, }; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( @@ -4437,6 +4450,7 @@ public sealed class GameWindow : IDisposable // for one frame before the residual decay kicks in on the next tick). System.Numerics.Vector3 preSnapPos = entity.Position; entity.SetPosition(worldPos); + entity.ParentCellId = p.LandblockId; entity.Rotation = rot; // Commit B 2026-04-29 — keep the shadow registry in sync with @@ -4852,6 +4866,7 @@ public sealed class GameWindow : IDisposable // 3. Snap player entity + controller. entity.SetPosition(snappedPos); + entity.ParentCellId = resolved.CellId; entity.Rotation = rot; _playerController.SetPosition(snappedPos, resolved.CellId); @@ -5008,6 +5023,9 @@ public sealed class GameWindow : IDisposable return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); } + private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) + => entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; + private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, System.Numerics.Vector3 cameraWorldPos) @@ -6702,6 +6720,7 @@ public sealed class GameWindow : IDisposable if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty + pe.ParentCellId = result.CellId; pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); @@ -7175,20 +7194,20 @@ public sealed class GameWindow : IDisposable && AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera) ? _retailChaseCamera.ViewerCellId : (playerRoot?.CellId ?? 0u); - var viewerEyePos = camPos; // the collided eye drives the side-test AND the projection + var viewerEyePos = camPos; // the collided eye drives the projection + var playerViewPos = _playerController?.RenderPosition + ?? _playerController?.Position + ?? camPos; LoadedCell? viewerRoot = null; - if ((viewerCellId & 0xFFFFu) >= 0x0100u - && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell)) + if (viewerCellId != 0u && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell)) viewerRoot = viewerRegCell; var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos); bool cameraInsideCell = visibility?.CameraCell is not null; - // Stage 3 (2026-06-02): the RENDER's seen_outside (gates terrain/sky through the - // doorway) comes from the VIEWER root cell. Retail CellManager::ChangePosition - // @ 0x004559B0 (pseudo_c:94649): keep landscape+terrain iff seen_outside else release. - // Outdoor viewer (viewerRoot==null) → always seen_outside=true. - // Building interior with exit portal → seen_outside=true (terrain clipped to the door). - // Pure dungeon (no exit portal reachable) → seen_outside=false (sky suppressed). + // Retail render routing is owned by the collided camera/viewer cell. + // The player cell still owns lighting state, but it must not force an + // indoor draw while the camera is outside; that drops the outdoor pass + // and leaves clear color around a floating doorway slice. bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; // Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified @@ -7262,17 +7281,18 @@ public sealed class GameWindow : IDisposable int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f); int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); - // Phase A8: prepare EnvCellRenderer's per-frame visibility snapshot. - // Always called — cheap when no cells loaded, cheap when frustum culls all. + // Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot + // is prepared after the portal-visible cell filter is known. var envCellViewProj = camera.View * camera.Projection; _envCellFrustum?.Update(envCellViewProj); - _envCellRenderer?.PrepareRenderBatches( - envCellViewProj, - camPos, - filter: null, - centerLbX: renderCenterLbX, - centerLbY: renderCenterLbY, - renderRadius: _nearRadius); + + HashSet? animatedIds = null; + if (_animatedEntities.Count > 0) + { + animatedIds = new HashSet(_animatedEntities.Count); + foreach (var k in _animatedEntities.Keys) + animatedIds.Add(k); + } // Phase G.1: sky renderer — draws the far-plane-infinity // celestial meshes FIRST so the rest of the scene z-tests @@ -7291,7 +7311,7 @@ public sealed class GameWindow : IDisposable // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped // to the doorway via the OutsideView (Stage 4, below). // Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky. - bool renderSky = !cameraInsideCell || rootSeenOutside; + bool renderSky = viewerRoot is null || rootSeenOutside; // Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape // position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to // the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky @@ -7319,94 +7339,50 @@ public sealed class GameWindow : IDisposable // (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its // binding=2 defensively from the ids we hand it. _clipFrame ??= ClipFrame.NoClip(); - // Retail RenderNormalMode (0x453aa0:92665) branches inside/outside on is_player_outside - // — the PLAYER's cell (0x451e80), NOT the camera cell — then roots DrawInside at the - // VIEWER cell (this->viewer_cell) when inside. The 3rd-person chase camera LAGS the - // player, so keying the branch off the camera (the old `visibility?.CameraCell`) made - // the camera lingering in a doorway AFTER the player had stepped outside take the - // DrawInside path rooted at the threshold cell, where the exit-portal flood degenerates - // → terrain Skipped + sparse shells → grey world with only entities showing through. - // Branch on the player; keep the viewer cell as the indoor root (handoff invariant). uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - var clipRoot = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( - playerCellId, visibility?.CameraCell is not null) - ? visibility!.CameraCell - : null; + bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( + playerCellId, + playerRoot is not null); + var clipRoot = playerIndoorGate && viewerRoot is not null ? viewerRoot : null; + string renderBranch = clipRoot is null + ? "OutdoorRoot" + : "RetailPViewInside"; ClipFrameAssembly? clipAssembly = null; PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root - System.Numerics.Vector4 terrainScissorNdc = default; HashSet? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys) + PortalVisibilityFrame? sigPvFrame = null; + ClipFrameAssembly? sigClipAssembly = null; + IReadOnlySet? sigDrawableCells = null; + AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null; + PortalVisibilityFrame? sigExteriorPvFrame = null; + ClipFrameAssembly? sigExteriorClipAssembly = null; + IReadOnlySet? sigExteriorDrawableCells = null; + AcDream.App.Rendering.InteriorEntityPartition.Result? sigExteriorPartition = null; + bool sigTerrainDrawn = false; + bool sigSkyDrawn = false; + bool sigDepthClear = false; + bool sigOutdoorPortalDrawn = false; + bool sigOutdoorSceneryDrawn = false; + int sigOutdoorRootObjectCount = 0; + int sigLiveDynamicDrawnCount = 0; + string sigSceneParticles = "none"; + _outdoorSceneParticleEntityIds.Clear(); + _visibleSceneParticleEntityIds.Clear(); + // Retail entry ownership: GameWindow never builds a second indoor PView product. + // Outdoor frames begin no-clip; indoor frames skip the global landscape block and let + // RetailPViewRenderer.DrawInside own ConstructView -> DrawCells. + _clipFrame.Reset(); + _wbDrawDispatcher?.ClearClipRouting(); + _envCellRenderer?.SetClipRouting(null); + _interiorPartition = null; if (clipRoot is not null) { - // Phase W single-viewpoint V1 (2026-06-03): the portal side test + distance ordering - // use the VIEWER eye (the collided camera) — same viewpoint as the projection - // (envCellViewProj) and the render root (clipRoot = the viewer cell). ONE viewpoint, - // retail InitCell side-test vs viewer.viewpoint (pc:432991). No more player/eye split. - pvFrame = PortalVisibilityBuilder.Build( - clipRoot, - viewerEyePos, - id => _cellVisibility.TryGetCell(id, out var c) ? c : null, - envCellViewProj); - - clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); - terrainClipMode = clipAssembly.TerrainMode; - terrainScissorNdc = clipAssembly.TerrainScissorNdcAabb; - - // Per-instance routing for the entity dispatcher + the cell shells. - _wbDrawDispatcher?.SetClipRouting( - clipAssembly.CellIdToSlot, clipAssembly.OutdoorSlot, clipAssembly.OutdoorVisible); - _envCellRenderer?.SetClipRouting(clipAssembly.CellIdToSlot); - - // The cell SHELLS render only for drawable visible cells (the slot - // map's keys; IsNothingVisible cells were excluded by the assembler). - envCellShellFilter = new HashSet(clipAssembly.CellIdToSlot.Keys); - - // R1: partition this frame's entities into per-cell / outdoor / live-dynamic buckets - // for the DrawInside flood + the outdoor-scenery-through-door draw. Keyed by the SAME - // visible-cell set the shells use (cellIdToSlot.Keys). - _interiorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition( - envCellShellFilter, _worldState.LandblockEntries); - - // [vis] probe (ACDREAM_PROBE_VIS=1) — the real PortalVisibilityFrame - // numbers, replacing the old camera-state-only spike. Cell-change - // throttled inside EmitVis so launch.log stays readable under motion. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) - AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( - clipRoot.CellId, - pvFrame.OrderedVisibleCells, - pvFrame.OutsideView.Polygons.Count, - clipAssembly.OutsidePlaneCount, - clipAssembly.PerCellPlaneCounts, - clipAssembly.ScissorFallbacks); - - // Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — paired with the builder's - // per-frame [flap] line. res = which FindCameraCell branch chose the root; - // eyeInRoot = is the EYE actually inside clipRoot's AABB (n ⇒ stale root via - // cache/grace, the leading flap hypothesis); terrain/outVisible = the frame's - // outcome (Skip/false ⇒ terrain+shells flapped off this frame). - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - { - var flapPlayer = _playerController?.Position ?? camPos; - bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot); - uint flapPlayerCell = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - Console.WriteLine( - $"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{flapPlayerCell:X8} " + - $"res={_cellVisibility.LastCameraCellResolution} " + - $"eyeInRoot={(eyeInRoot ? "Y" : "n")} eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " + - $"player=({flapPlayer.X:F2},{flapPlayer.Y:F2},{flapPlayer.Z:F2}) " + - $"terrain={clipAssembly.TerrainMode} outVisible={clipAssembly.OutdoorVisible}"); - } - } - else - { - // Outdoor root: no portal frame. Keep the frame no-clip and revert the - // renderers to U.3 behavior (every instance slot 0, nothing culled, - // terrain ungated). Reset so a prior indoor frame's slots don't leak. - _clipFrame.Reset(); - _wbDrawDispatcher?.ClearClipRouting(); - _envCellRenderer?.SetClipRouting(null); - _interiorPartition = null; // R1: no indoor flood on the outdoor root + clipAssembly = null; + pvFrame = null; + terrainClipMode = TerrainClipMode.Skip; + envCellShellFilter = null; + _interiorPartition = null; } _clipFrame.UploadShared(_gl); @@ -7414,207 +7390,235 @@ public sealed class GameWindow : IDisposable _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _terrain?.SetClipUbo(_clipFrame.TerrainUbo); - // ── [Stage 4] sky pre-scene (LScape, drawn through the doorway) ───────────── - // Phase W Stage 4 (2026-06-02): the sky + (post-scene) weather are retail's LScape — - // "the outside seen through the exit portal." They draw clipped to the OutsideView via - // sky.vert's gl_ClipDistance against the SAME binding=2 TerrainClip UBO the terrain reads - // (just uploaded by UploadShared above). Retail PView::DrawCells (pseudo_c:432709) draws - // LScape first when outside_view.view_count > 0; RenderNormalMode (92649) gates it on - // seen_outside. drawSkyThisFrame = the seen_outside policy (renderSky) AND somewhere to - // draw it: outdoors (clipAssembly == null → full-screen) OR indoors with an exit portal in - // view (HasOutsideView). An interior with no exit portal in the current view draws no sky - // (no full-screen bleed). skyDoorwayClip drives the doorway scissor for the particle - // passes (particle.vert has no gl_ClipDistance) and the conditional Z-clear below. - bool skyDoorwayClip = clipAssembly is not null && clipAssembly.HasOutsideView; - bool drawSkyThisFrame = renderSky && (clipAssembly is null || clipAssembly.HasOutsideView); - System.Numerics.Vector4 skyDoorwayNdc = clipAssembly?.OutsideViewNdcAabb ?? default; - if (drawSkyThisFrame) + bool drawSkyThisFrame = false; + + if (clipRoot is null) { - // Scissor the WHOLE sky pre-scene block (mesh + particles) to the doorway AABB when - // indoors. The sky MESH is precisely clipped by sky.vert's gl_ClipDistance in PLANES - // mode (the scissor is then a harmless over-include — the planes are tighter); but in - // SCISSOR mode the OutsideView exceeded the convex-plane budget so the assembler left - // the binding=2 UBO at count 0 (no planes) — there the scissor is the ONLY confinement, - // exactly mirroring the terrain Scissor path. Without this, a multi-exit interior would - // bleed full-screen sky/rain (sky.vert with count 0 writes all +1 = no clip). The - // SkyPreScene particles (particle.vert, no gl_ClipDistance) rely on the scissor in BOTH - // modes. Outdoors (skyDoorwayClip=false) → no scissor → full-screen, bit-identical. - bool skySc = BeginDoorwayScissor(skyDoorwayClip, skyDoorwayNdc); + // ── Outdoor LScape entry ───────────────────────────────────────────────── + // Retail indoor frames do not pass through this block. If the player is indoors, + // PView::DrawInside owns landscape drawing through outside_view and the depth-only + // clear. This outdoor-only block is the LScape half of RenderNormalMode. + drawSkyThisFrame = renderSky; + sigSkyDrawn = drawSkyThisFrame; + if (drawSkyThisFrame) + { + _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Enable(EnableCap.ClipDistance0 + _cp); + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Disable(EnableCap.ClipDistance0 + _cp); - // Sky MESH: re-bind binding=2 (the OutsideView UBO) defensively — SkyRenderer does not - // own it and we must not inherit whatever was last bound (memory: - // render-self-contained-gl-state) — then enable the 8 clip planes so sky.vert clips - // precisely in Planes mode (count>0). count==0 (outdoor / Scissor-mode) → all +1. - _gl.BindBufferBase(BufferTargetARB.UniformBuffer, - ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Enable(EnableCap.ClipDistance0 + _cp); - _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Disable(EnableCap.ClipDistance0 + _cp); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + } - // SkyPreScene particles (particle.vert, no gl_ClipDistance) — confined by the scissor. - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + // K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured + // but the chase camera hasn't engaged yet. The sky (above) still draws during login so the + // user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake. + if (IsLiveModeWaitingForLogin) + goto SkipWorldGeometry; - if (skySc) _gl.Disable(EnableCap.ScissorTest); - } - - // K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured - // but the chase camera hasn't engaged yet. The sky (above) still draws during login so the - // user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake; - // the world geometry below is skipped. (Phase W Stage 4: moved BELOW the sky draw — the sky - // now needs the assembled ClipFrame, which is harmless/no-clip pre-login.) - if (IsLiveModeWaitingForLogin) - goto SkipWorldGeometry; - - // Phase U.3: enable the 8 hardware clip planes for the world-geometry - // block ONLY. All gl_ClipDistance-writing draws (terrain, entities, and - // U.4's EnvCellRenderer.Render) MUST be inside this enable/disable - // bracket; everything else (particles, weather, debug, UI) renders with - // clip DISABLED. The sky/weather drew/draws above + below in their OWN - // local clip brackets (sky.vert now writes gl_ClipDistance); the - // particles/weather-particles/debug/UI draw with clip OFF. Scoping - // the enable here (instead of a permanent init-time enable) avoids the - // undefined behavior of leaving GL_CLIP_DISTANCE_i on for shaders that - // never write gl_ClipDistance[i] — a driver is free to clip those away. - // (EnableCap.ClipDistance0 == GL_CLIP_DISTANCE0 0x3000; +i selects plane i.) - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Enable(EnableCap.ClipDistance0 + _cp); - - // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup - // (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch - // is cheap; only the periodic Console.WriteLine is gated. - // - // Phase U.4 OutsideView gating (indoor root only; outdoor root uses - // TerrainClipMode.Planes with a count-0 UBO = ungated, the U.3 path): - // Skip ⇒ the camera sees no outdoors through any portal chain → - // draw NO terrain. THIS is the bleed fix (empty OutsideView - // ⇒ outdoor terrain stops leaking into interiors). - // Scissor ⇒ OutsideView exceeded the convex-plane budget → glScissor - // around ONLY the terrain draw (NDC AABB → framebuffer px), - // UBO left ungated. Disabled again immediately after so the - // rest of the frame is unscissored. - // Planes ⇒ UBO carries the OutsideView planes (already set by the - // assembler) → terrain gated per-vertex, draw normally. - _terrainCpuStopwatch.Restart(); - if (terrainClipMode == TerrainClipMode.Skip) - { - // No terrain this frame — bleed fix. - } - else if (terrainClipMode == TerrainClipMode.Scissor) - { - var fb = _window!.FramebufferSize; - // NDC [-1,1] → window pixels. Clamp to the framebuffer so a portal - // opening that extends past the screen edge yields a valid box. - float nx0 = System.Math.Clamp(terrainScissorNdc.X, -1f, 1f); - float ny0 = System.Math.Clamp(terrainScissorNdc.Y, -1f, 1f); - float nx1 = System.Math.Clamp(terrainScissorNdc.Z, -1f, 1f); - float ny1 = System.Math.Clamp(terrainScissorNdc.W, -1f, 1f); - int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X); - int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y); - int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X); - int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y); - _gl.Enable(EnableCap.ScissorTest); - _gl.Scissor(px, py, (uint)System.Math.Max(0, pw), (uint)System.Math.Max(0, ph)); + // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup. + EnableClipDistances(); + _terrainCpuStopwatch.Restart(); + sigTerrainDrawn = true; _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - _gl.Disable(EnableCap.ScissorTest); - } - else - { - _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - } - _terrainCpuStopwatch.Stop(); - // Multiply by 100 then divide by 100 in the diag print to keep - // 0.01 µs precision in the long-typed sample buffer. Terrain Draw - // is sub-microsecond on simple scenes; truncating to integer µs - // would round nearly every sample to 0. - _terrainCpuSamples[_terrainCpuSampleCursor] = (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); - _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; - MaybeFlushTerrainDiag(); + _terrainCpuStopwatch.Stop(); + _terrainCpuSamples[_terrainCpuSampleCursor] = + (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); + _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; + MaybeFlushTerrainDiag(); - // L-fix1 (2026-04-28): pass the set of animated-entity ids so - // the renderer keeps remote players / NPCs / monsters - // visible even when their landblock rotates out of the - // frustum. Without this, other characters wink in/out as - // the camera turns. The set is rebuilt per-frame from - // _animatedEntities — it's small (<100 entities typically) - // so HashSet allocation is cheap. Static scenery still - // respects landblock-level cull. - HashSet? animatedIds = null; - if (_animatedEntities.Count > 0) - { - animatedIds = new HashSet(_animatedEntities.Count); - foreach (var k in _animatedEntities.Keys) - animatedIds.Add(k); - } - - // R1: outdoor scenery (ParentCellId == null) is part of the landscape seen through the - // doorway (retail LScape::draw draws the exterior, clipped to OutsideView). Drawn here — - // after terrain, BEFORE the Z-clear — only on an indoor root, scoped to the outdoor bucket. - // ResolveEntitySlot routes these (ParentCellId == null) to OutdoorSlot when OutdoorVisible, - // else CULLs them, via the SetClipRouting installed above. visibleCellIds: null ⇒ they pass - // the membership gate (no cell filter) and are gated purely by the clip slot. - if (clipAssembly is not null && _interiorPartition is not null - && _interiorPartition.Outdoor.Count > 0 && clipAssembly.OutdoorVisible) - { - var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, - (IReadOnlyList)_interiorPartition.Outdoor, - (IReadOnlyDictionary?)null); - _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum, - neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds); - } - - // ── [Stage 4] conditional doorway Z-clear ─────────────────────────────────── - // Retail PView::DrawCells @ pseudo_c:432731: after the landscape (sky + terrain) is drawn - // through the exit portal, RenderDevice->Clear(flag 4 = Z-BUFFER ONLY, NOT color) resets - // depth so the indoor walls / entities draw cleanly on top without z-fighting at the portal - // plane. Depth ONLY — never color — so there is NO blue clear-color hole: the sky / terrain - // color already written through the doorway stays, and the opaque cell shells overpaint the - // doorway-bbox corners. Scissored to the OutsideView AABB so only the doorway region's depth - // is cleared. Fires only for an indoor root with an exit portal in view (skyDoorwayClip). - if (skyDoorwayClip) - { - bool _zc = BeginDoorwayScissor(true, skyDoorwayNdc); - _gl.Clear(ClearBufferMask.DepthBufferBit); - if (_zc) _gl.Disable(EnableCap.ScissorTest); } // R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0): // INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global // entity pass + global shell pass are NOT issued — visibility IS the cull, so the // outdoor world cannot bleed (it is never iterated; outdoor scenery entered above, - // clipped to the doorway). DrawInside draws per-cell shells (opaque + transparent) + - // per-cell objects + live-dynamics, closest-first over the drawable visible cells. - // OUTDOOR root: the existing global entity pass (no shells, no DrawInside). - if (clipRoot is not null && _interiorRenderer is not null - && _interiorPartition is not null && envCellShellFilter is not null) + // clipped to the doorway). DrawInside follows retail DrawCells order: reverse + // cell_draw_list shell stage, then reverse object-list stage, per portal_view slice. + // OUTDOOR root: draw the landscape/outdoor bucket first, then seed a reciprocal + // portal frame from exterior-facing cell portals so peering through an open door + // draws the indoor SHELL + its statics together. The old global pass drew indoor + // statics without the EnvCell shells, which made walls look transparent from outside. + if (clipRoot is not null) { - var interiorCtx = new AcDream.App.Rendering.InteriorRenderContext + if (_retailPViewRenderer is null) + throw new InvalidOperationException("Retail PView renderer is required for indoor frames."); + + var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext { - OrderedVisibleCells = pvFrame!.OrderedVisibleCells, - DrawableCells = envCellShellFilter, - Partition = _interiorPartition, + RootCell = clipRoot, + ViewerEyePos = viewerEyePos, + ViewProjection = envCellViewProj, + CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, Camera = camera, + CameraWorldPosition = camPos, Frustum = frustum, PlayerLandblockId = playerLb, AnimatedEntityIds = animatedIds, - }; - _interiorRenderer.DrawInside(interiorCtx); + RenderCenterLbX = renderCenterLbX, + RenderCenterLbY = renderCenterLbY, + RenderRadius = _nearRadius, + LandblockEntries = _worldState.LandblockEntries, + SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId), + DrawLandscapeSlice = sliceCtx => + DrawRetailPViewLandscapeSlice( + sliceCtx, + camera, + frustum, + camPos, + playerLb, + animatedIds, + renderSky, + kf, + environOverrideActive), + ClearDepthSlice = slice => + { + bool zc = BeginDoorwayScissor(true, slice.NdcAabb); + _gl.Clear(ClearBufferMask.DepthBufferBit); + if (zc) + _gl.Disable(EnableCap.ScissorTest); + }, + DrawCellParticles = sliceCtx => + DrawRetailPViewCellParticles(sliceCtx, camera, camPos), + EmitDiagnostics = result => + EmitRetailPViewDiagnostics( + result, + clipRoot, + viewerCellId, + playerCellId, + camPos, + playerViewPos), + }); + pvFrame = pviewResult.PortalFrame; + clipAssembly = pviewResult.ClipAssembly; + envCellShellFilter = pviewResult.DrawableCells; + _interiorPartition = pviewResult.Partition; + sigPvFrame = pviewResult.PortalFrame; + sigClipAssembly = pviewResult.ClipAssembly; + sigDrawableCells = pviewResult.DrawableCells; + sigPartition = pviewResult.Partition; + sigTerrainDrawn = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + sigSkyDrawn = renderSky && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + sigDepthClear = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + sigSceneParticles = (pviewResult.Partition.ByCell.Count > 0 + || pviewResult.ClipAssembly.OutsideViewSlices.Length > 0) + ? "pviewScoped" + : sigSceneParticles; + sigOutdoorSceneryDrawn = pviewResult.Partition.Outdoor.Count > 0 + && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; } else { - // Outdoor root: draw the full outdoor world. No cell filter — outdoors there is no - // portal-cell scoping (ClearClipRouting made every instance slot 0). R1 retires - // visibility.VisibleCellIds as a render gate (peering into buildings is R5, a - // separate pass). On the outdoor root visibility is null anyway, so this is the - // same set the old code passed; null makes that explicit + gate-change-safe. - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: null, - animatedEntityIds: animatedIds); + bool liveDynamicsDrawn = false; + + if (_interiorRenderer is not null) + { + _outdoorRootNoCells.Clear(); + var outdoorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition( + _outdoorRootNoCells, _worldState.LandblockEntries); + sigOutdoorRootObjectCount = outdoorPartition.Outdoor.Count; + + if (outdoorPartition.Outdoor.Count > 0) + { + _interiorRenderer.DrawEntityBucket( + camera, + frustum, + playerLb, + animatedIds, + outdoorPartition.Outdoor, + visibleCellIds: null); + } + + _exteriorPortalLandblocks.Clear(); + _exteriorPortalCandidateCells.Clear(); + // FPS (2026-06-07): the outdoor look-in (DrawPortal -> BuildFromExterior) seeds only + // from exit portals within MaxSeedDistance (48 m) of the camera. A landblock is 192 m, + // so any cell that could seed is in the player's landblock or an immediate neighbour; + // cells further out are already discarded by BuildFromExterior's per-portal cutoff. + // Iterating EVERY cell in EVERY loaded landblock (near radius 4 = up to 81 LBs) just to + // discard them is an O(all loaded cells) sweep every outdoor frame — the cause of the + // "FPS drops as soon as I look out" report. Restrict candidates to the 1-ring around the + // player (Chebyshev <= 1 in landblock grid). No behaviour change: the excluded cells are + // all > 48 m away and were already culled by the seed-distance cutoff. + int playerGridX = playerLb.HasValue ? (int)((playerLb.Value >> 24) & 0xFFu) : -1; + int playerGridY = playerLb.HasValue ? (int)((playerLb.Value >> 16) & 0xFFu) : -1; + foreach (var entry in _worldState.LandblockEntries) + { + uint lbPrefix = (entry.LandblockId >> 16) & 0xFFFFu; + if (playerLb.HasValue) + { + int gX = (int)((lbPrefix >> 8) & 0xFFu); + int gY = (int)(lbPrefix & 0xFFu); + if (Math.Max(Math.Abs(gX - playerGridX), Math.Abs(gY - playerGridY)) > 1) + continue; + } + if (!_exteriorPortalLandblocks.Add(lbPrefix)) + continue; + + foreach (var cell in _cellVisibility.GetCellsForLandblock(lbPrefix)) + _exteriorPortalCandidateCells.Add(cell); + } + + if (_exteriorPortalCandidateCells.Count > 0 && _retailPViewRenderer is not null) + { + var portalResult = _retailPViewRenderer.DrawPortal( + new AcDream.App.Rendering.RetailPViewPortalDrawContext + { + CandidateCells = _exteriorPortalCandidateCells, + ViewerEyePos = viewerEyePos, + ViewProjection = envCellViewProj, + CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, + Camera = camera, + CameraWorldPosition = camPos, + Frustum = frustum, + PlayerLandblockId = playerLb, + AnimatedEntityIds = animatedIds, + RenderCenterLbX = renderCenterLbX, + RenderCenterLbY = renderCenterLbY, + RenderRadius = _nearRadius, + MaxSeedDistance = 48f, + LandblockEntries = _worldState.LandblockEntries, + SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId), + }); + + if (portalResult is not null) + { + sigOutdoorPortalDrawn = true; + sigExteriorPvFrame = portalResult.PortalFrame; + sigExteriorClipAssembly = portalResult.ClipAssembly; + sigExteriorDrawableCells = portalResult.DrawableCells; + sigExteriorPartition = portalResult.Partition; + liveDynamicsDrawn = portalResult.Partition.LiveDynamic.Count > 0; + if (liveDynamicsDrawn) + sigLiveDynamicDrawnCount = portalResult.Partition.LiveDynamic.Count; + } + } + + if (!liveDynamicsDrawn && outdoorPartition.LiveDynamic.Count > 0) + { + sigLiveDynamicDrawnCount = outdoorPartition.LiveDynamic.Count; + _interiorRenderer.DrawEntityBucket( + camera, + frustum, + playerLb, + animatedIds, + outdoorPartition.LiveDynamic, + visibleCellIds: null); + } + } + else + { + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: null, + animatedEntityIds: animatedIds); + } } // Phase U.3: close the world-geometry clip bracket opened above. From here down the @@ -7629,33 +7633,37 @@ public sealed class GameWindow : IDisposable // scene geometry so alpha blending composites correctly. // Runs with depth test on (particles occluded by walls) // but depth write off (no self-occlusion sorting needed). - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene); + if (clipRoot is null && _particleSystem is not null && _particleRenderer is not null) + { + if (clipAssembly is not null) + { + sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered"; + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId == 0 + || (!_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId) + && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId))); + } + else + { + sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global"; + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene); + } + } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky - // meshes (Properties & 0x04, e.g. the 815m-tall rain - // cylinder 0x01004C42/0x01004C44) render AFTER the scene so - // the additive rain streaks overlay terrain and entities - // instead of being painted over by them. This is the second - // half of retail's LScape::draw split — GameSky::Draw(1) - // fires after the DrawBlock loop. Same indoor gate as the - // sky pass: weather follows the same drawSkyThisFrame gate (seen_outside policy AND an - // exit portal in view when indoors), and — Phase W Stage 4 — draws inside its OWN local - // clip bracket so sky.vert clips the rain cylinder to the doorway indoors (full-screen - // outdoors). Suppressed in sealed dungeons / interiors with no exit portal in view. - if (drawSkyThisFrame) + // Outdoor LScape post-scene weather. Indoor weather through an exit portal is + // drawn by RetailPViewRenderer.DrawInside via DrawRetailPViewLandscapeSlice. + if (clipRoot is null && drawSkyThisFrame) { - // Scissor the WHOLE weather post-scene block (rain mesh + particles) to the doorway - // AABB when indoors — symmetric with the sky pre-scene block. The rain cylinder MESH - // is precisely clipped by sky.vert in Planes mode (scissor a harmless over-include); - // in Scissor mode (UBO count 0, no planes) the scissor is the ONLY confinement — else - // the 815m rain cylinder bleeds full-screen indoors. Outdoors → no scissor → unchanged. - bool wxSc = BeginDoorwayScissor(skyDoorwayClip, skyDoorwayNdc); - - // Weather MESH (rain cylinder): re-bind binding=2 (the OutsideView UBO) defensively, - // enable the 8 clip planes around RenderWeather, disable after. count==0 outdoors ⇒ - // full-screen rain, unchanged. + sigSkyDrawn = true; _gl.BindBufferBase(BufferTargetARB.UniformBuffer, ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) @@ -7665,14 +7673,42 @@ public sealed class GameWindow : IDisposable for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) _gl.Disable(EnableCap.ClipDistance0 + _cp); - // SkyPostScene particles (particle.vert, no gl_ClipDistance) — confined by the scissor. if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(_particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); - - if (wxSc) _gl.Disable(EnableCap.ScissorTest); } + EmitRenderSignatureIfChanged( + renderBranch, + clipRoot, + viewerRoot, + playerRoot, + viewerCellId, + playerCellId, + playerIndoorGate, + cameraInsideCell, + renderSky, + drawSkyThisFrame, + sigTerrainDrawn, + terrainClipMode, + sigSkyDrawn, + sigDepthClear, + sigOutdoorSceneryDrawn, + sigOutdoorPortalDrawn, + sigOutdoorRootObjectCount, + sigLiveDynamicDrawnCount, + sigSceneParticles, + sigPvFrame, + sigClipAssembly, + sigDrawableCells, + sigPartition, + sigExteriorPvFrame, + sigExteriorClipAssembly, + sigExteriorDrawableCells, + sigExteriorPartition, + camPos, + playerViewPos); + // Debug: draw collision shapes as wireframe cylinders around the // player so we can visually verify alignment with scenery meshes. if (_debugCollisionVisible && _debugLines is not null) @@ -8176,6 +8212,8 @@ public sealed class GameWindow : IDisposable } ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty + if (rm.CellId != 0) + ae.Entity.ParentCellId = rm.CellId; ae.Entity.Rotation = rm.Body.Orientation; } else @@ -8504,6 +8542,8 @@ public sealed class GameWindow : IDisposable } ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty + if (rm.CellId != 0) + ae.Entity.ParentCellId = rm.CellId; ae.Entity.Rotation = rm.Body.Orientation; } } @@ -8993,6 +9033,367 @@ public sealed class GameWindow : IDisposable } } + private void EmitRenderSignatureIfChanged( + string branch, + LoadedCell? clipRoot, + LoadedCell? viewerRoot, + LoadedCell? playerRoot, + uint viewerCellId, + uint playerCellId, + bool playerIndoorGate, + bool cameraInsideCell, + bool renderSkyGate, + bool drawSkyThisFrame, + bool terrainDrawn, + TerrainClipMode terrainClipMode, + bool skyDrawn, + bool depthClear, + bool outdoorSceneryDrawn, + bool outdoorPortalDrawn, + int outdoorRootObjectCount, + int liveDynamicDrawnCount, + string sceneParticles, + PortalVisibilityFrame? pvFrame, + ClipFrameAssembly? clipAssembly, + IReadOnlySet? drawableCells, + AcDream.App.Rendering.InteriorEntityPartition.Result? partition, + PortalVisibilityFrame? exteriorPvFrame, + ClipFrameAssembly? exteriorClipAssembly, + IReadOnlySet? exteriorDrawableCells, + AcDream.App.Rendering.InteriorEntityPartition.Result? exteriorPartition, + System.Numerics.Vector3 camPos, + System.Numerics.Vector3 playerViewPos) + { + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + return; + + _renderSignatureFrame++; + + bool eyeInRoot = clipRoot is not null && CellVisibility.PointInCell(camPos, clipRoot); + bool playerInRoot = clipRoot is not null && CellVisibility.PointInCell(playerViewPos, clipRoot); + + var sb = new System.Text.StringBuilder(512); + sb.Append("branch=").Append(branch); + sb.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8")); + sb.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8")); + sb.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8")); + sb.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8")); + sb.Append(" playerCell=0x").Append(playerCellId.ToString("X8")); + sb.Append(" gate=").Append(playerIndoorGate ? "in" : "out"); + sb.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n'); + sb.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n'); + sb.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n'); + sb.Append(" eye=").Append(FormatVecForSignature(camPos)); + sb.Append(" player=").Append(FormatVecForSignature(playerViewPos)); + sb.Append(" terrain=").Append(terrainClipMode); + sb.Append('/').Append(terrainDrawn ? "draw" : "skip"); + sb.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n'); + sb.Append(" sky=").Append(skyDrawn ? 'Y' : 'n'); + sb.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n'); + sb.Append(" zclear=").Append(depthClear ? 'Y' : 'n'); + sb.Append(" sceneParticles=").Append(sceneParticles); + + if (clipAssembly is not null) + { + sb.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length); + sb.Append(" outPolys=").Append(pvFrame?.OutsideView.Polygons.Count ?? 0); + sb.Append(" outMode=").Append(clipAssembly.TerrainMode); + } + else + { + sb.Append(" outSlices=0 outPolys=0 outMode=none"); + } + + sb.Append(" ids=").Append(FormatIds(pvFrame?.OrderedVisibleCells, preserveOrder: true)); + sb.Append(" draw=").Append(FormatIds(drawableCells, preserveOrder: false)); + sb.Append(" miss=").Append(FormatMissingDrawableCells(pvFrame, drawableCells)); + sb.Append(" obj=").Append(FormatPartitionCounts(partition)); + sb.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n'); + sb.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount); + sb.Append(" liveDynDraw=").Append(liveDynamicDrawnCount); + + if (outdoorPortalDrawn || exteriorPvFrame is not null || exteriorClipAssembly is not null) + { + sb.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n'); + sb.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0); + sb.Append(" extIds=").Append(FormatIds(exteriorPvFrame?.OrderedVisibleCells, preserveOrder: true)); + sb.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, preserveOrder: false)); + sb.Append(" extMiss=").Append(FormatMissingDrawableCells(exteriorPvFrame, exteriorDrawableCells)); + sb.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition)); + } + + string signature = sb.ToString(); + if (signature == _lastRenderSignature) + { + _renderSignatureStableFrames++; + return; + } + + Console.WriteLine( + $"[render-sig] frame={_renderSignatureFrame} stable={_renderSignatureStableFrames} {signature}"); + _lastRenderSignature = signature; + _renderSignatureStableFrames = 0; + } + + private static string FormatVecForSignature(System.Numerics.Vector3 value) + { + static float Q(float v) => System.MathF.Round(v * 20f) / 20f; + return $"({Q(value.X):F2},{Q(value.Y):F2},{Q(value.Z):F2})"; + } + + private static string FormatIds(IEnumerable? ids, bool preserveOrder) + { + if (ids is null) + return "[]"; + + var values = new List(); + foreach (uint id in ids) + values.Add(id); + + if (!preserveOrder) + values.Sort(); + + var sb = new System.Text.StringBuilder(96); + sb.Append('['); + const int MaxIds = 12; + for (int i = 0; i < values.Count && i < MaxIds; i++) + { + if (i > 0) + sb.Append(','); + sb.Append("0x").Append(values[i].ToString("X8")); + } + if (values.Count > MaxIds) + sb.Append(",..."); + sb.Append(']'); + return sb.ToString(); + } + + private static string FormatMissingDrawableCells( + PortalVisibilityFrame? pvFrame, + IReadOnlySet? drawableCells) + { + if (pvFrame is null || drawableCells is null) + return "[]"; + + var sb = new System.Text.StringBuilder(96); + sb.Append('['); + int written = 0; + const int MaxCells = 8; + foreach (uint id in pvFrame.OrderedVisibleCells) + { + if (drawableCells.Contains(id)) + continue; + + if (written > 0) + sb.Append(','); + sb.Append("0x").Append(id.ToString("X8")); + if (pvFrame.CellViews.TryGetValue(id, out var view)) + { + sb.Append(":p").Append(view.Polygons.Count); + if (view.IsEmpty) + sb.Append(":empty"); + } + else + { + sb.Append(":noView"); + } + + written++; + if (written >= MaxCells) + { + sb.Append(",..."); + break; + } + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string FormatPartitionCounts( + AcDream.App.Rendering.InteriorEntityPartition.Result? partition) + { + if (partition is null) + return "cell=[] out=0 live=0"; + + var keys = new List(partition.ByCell.Keys); + keys.Sort(); + + var sb = new System.Text.StringBuilder(128); + sb.Append("cell=["); + const int MaxCells = 10; + for (int i = 0; i < keys.Count && i < MaxCells; i++) + { + uint id = keys[i]; + if (i > 0) + sb.Append(','); + sb.Append("0x").Append(id.ToString("X8")).Append(':').Append(partition.ByCell[id].Count); + } + if (keys.Count > MaxCells) + sb.Append(",..."); + sb.Append("] out=").Append(partition.Outdoor.Count) + .Append(" live=").Append(partition.LiveDynamic.Count); + return sb.ToString(); + } + + private void DrawRetailPViewLandscapeSlice( + AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx, + ICamera camera, + FrustumPlanes? frustum, + System.Numerics.Vector3 camPos, + uint? playerLb, + HashSet? animatedIds, + bool renderSky, + AcDream.Core.World.SkyKeyframe kf, + bool environOverrideActive) + { + var slice = sliceCtx.Slice; + bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); + + _gl!.BindBufferBase(BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo); + + EnableClipDistances(); + if (renderSky) + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + + DisableClipDistances(); + if (renderSky && _particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + + EnableClipDistances(); + _terrainCpuStopwatch.Restart(); + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + _terrainCpuStopwatch.Stop(); + _terrainCpuSamples[_terrainCpuSampleCursor] = + (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); + _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; + MaybeFlushTerrainDiag(); + + if (sliceCtx.OutdoorEntities.Count > 0) + { + var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, + sliceCtx.OutdoorEntities, + (IReadOnlyDictionary?)null); + _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: null, + animatedEntityIds: animatedIds); + } + + _outdoorSceneParticleEntityIds.Clear(); + foreach (var entity in sliceCtx.OutdoorEntities) + _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); + + DisableClipDistances(); + if (_outdoorSceneParticleEntityIds.Count > 0 + && _particleSystem is not null + && _particleRenderer is not null) + { + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId != 0 + && _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + } + + EnableClipDistances(); + if (renderSky) + { + _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + DisableClipDistances(); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); + } + else + { + DisableClipDistances(); + } + + if (scissor) + _gl!.Disable(EnableCap.ScissorTest); + + DisableClipDistances(); + } + + private void DrawRetailPViewCellParticles( + AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, + ICamera camera, + System.Numerics.Vector3 camPos) + { + if (_particleSystem is null || _particleRenderer is null || sliceCtx.CellEntities.Count == 0) + return; + + _visibleSceneParticleEntityIds.Clear(); + foreach (var entity in sliceCtx.CellEntities) + _visibleSceneParticleEntityIds.Add(ParticleEntityKey(entity)); + + if (_visibleSceneParticleEntityIds.Count == 0) + return; + + DisableClipDistances(); + bool scissor = BeginDoorwayScissor(true, sliceCtx.Slice.NdcAabb); + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId != 0 + && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + if (scissor) + _gl!.Disable(EnableCap.ScissorTest); + DisableClipDistances(); + } + + private void EmitRetailPViewDiagnostics( + AcDream.App.Rendering.RetailPViewFrameResult result, + LoadedCell clipRoot, + uint viewerCellId, + uint playerCellId, + System.Numerics.Vector3 camPos, + System.Numerics.Vector3 playerViewPos) + { + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) + AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( + clipRoot.CellId, + result.PortalFrame.OrderedVisibleCells, + result.PortalFrame.OutsideView.Polygons.Count, + result.ClipAssembly.OutsidePlaneCount, + result.ClipAssembly.PerCellPlaneCounts, + result.ClipAssembly.ScissorFallbacks); + + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + { + bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot); + bool playerInRoot = CellVisibility.PointInCell(playerViewPos, clipRoot); + Console.WriteLine( + $"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} " + + $"res={_cellVisibility.LastCameraCellResolution} " + + $"eyeInRoot={(eyeInRoot ? "Y" : "n")} playerInRoot={(playerInRoot ? "Y" : "n")} " + + $"eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " + + $"player=({playerViewPos.X:F2},{playerViewPos.Y:F2},{playerViewPos.Z:F2}) " + + $"terrain={result.ClipAssembly.TerrainMode} outVisible={result.ClipAssembly.OutdoorVisible}"); + } + } + + private void EnableClipDistances() + { + for (int i = 0; i < ClipFrame.MaxPlanes; i++) + _gl!.Enable(EnableCap.ClipDistance0 + i); + } + + private void DisableClipDistances() + { + for (int i = 0; i < ClipFrame.MaxPlanes; i++) + _gl!.Disable(EnableCap.ClipDistance0 + i); + } + // Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in // framebuffer pixels and enable the scissor test; returns true iff applied (the caller then // disables EnableCap.ScissorTest after its draw/clear). Mirrors the terrain Scissor-mode diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs index 9d2ef05b..300abcd5 100644 --- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -5,18 +5,10 @@ using AcDream.Core.World; namespace AcDream.App.Rendering; /// -/// Splits a frame's landblock entities into the three draw buckets the per-cell -/// needs, using the SAME precedence as -/// : -/// -/// ServerGuid != 0 (player / NPCs / items / doors) ⇒ -/// — drawn unclipped (depth only). These have no ParentCellId so they MUST be tested first. -/// ParentCellId in the visible set ⇒ [cell] — per-cell, portal-clipped. -/// ParentCellId == null (outdoor scenery / building shell) ⇒ -/// — drawn through the doorway, clipped to OutsideView. -/// -/// A static whose ParentCellId is NOT in is dropped (its cell -/// isn't drawn this frame). Entities with no MeshRefs are skipped. Pure; GL-free; unit-tested. +/// Splits a frame's landblock entities into the draw buckets used by the +/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too: +/// a player, NPC, door, or item with a current indoor ParentCellId belongs to +/// that cell's portal-clipped object list, not a global overlay pass. /// public static class InteriorEntityPartition { @@ -40,18 +32,18 @@ public static class InteriorEntityPartition { if (e.MeshRefs.Count == 0) continue; - if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId) + if (e.ServerGuid != 0) { - result.LiveDynamic.Add(e); + if (e.ParentCellId is uint liveCell) + AddByCellOrOutdoor(e, liveCell, visibleCells, result); + else + result.LiveDynamic.Add(e); } else if (e.ParentCellId is uint cell) { - if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame - if (!result.ByCell.TryGetValue(cell, out var list)) - result.ByCell[cell] = list = new List(); - list.Add(e); + AddByCellOrOutdoor(e, cell, visibleCells, result); } - else // outdoor scenery / building shell + else { result.Outdoor.Add(e); } @@ -59,4 +51,30 @@ public static class InteriorEntityPartition } return result; } + + private static void AddByCellOrOutdoor( + WorldEntity entity, + uint cellId, + HashSet visibleCells, + Result result) + { + if (!IsIndoorCellId(cellId)) + { + result.Outdoor.Add(entity); + return; + } + + if (!visibleCells.Contains(cellId)) + return; + + if (!result.ByCell.TryGetValue(cellId, out var list)) + result.ByCell[cellId] = list = new List(); + list.Add(entity); + } + + private static bool IsIndoorCellId(uint cellId) + { + uint low = cellId & 0xFFFFu; + return low >= 0x0100u && low != 0xFFFFu; + } } diff --git a/src/AcDream.App/Rendering/InteriorRenderer.cs b/src/AcDream.App/Rendering/InteriorRenderer.cs index 110f8986..5de0ffc6 100644 --- a/src/AcDream.App/Rendering/InteriorRenderer.cs +++ b/src/AcDream.App/Rendering/InteriorRenderer.cs @@ -17,6 +17,13 @@ public sealed class InteriorRenderContext /// membership filter; supplies the draw ORDER. public required IReadOnlySet DrawableCells { get; init; } + /// Per-cell portal_view slots, in the same order retail setup_view(cell, i) + /// selects them inside PView::DrawCells. + public required IReadOnlyDictionary CellClipSlots { get; init; } + + public required int OutdoorSlot { get; init; } + public required bool OutdoorVisible { get; init; } + /// The 3-bucket entity split (). Only ByCell + /// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door /// step (clipped to OutsideView). @@ -34,12 +41,11 @@ public sealed class InteriorRenderContext } /// -/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops -/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell + -/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the -/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/ -/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is -/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own). +/// The interior render flood, matching retail PView::DrawCells @ 0x005a4840: +/// after the caller handles outside_view terrain + the depth-only clear, DrawCells +/// walks cell_draw_list from the end back to zero in separate stages: cell shells, +/// then each cell's object_list. The transparent shell pass is split out because +/// the modern renderer batches opaque/transparent surfaces separately. /// public sealed class InteriorRenderer { @@ -48,7 +54,6 @@ public sealed class InteriorRenderer // Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs. private readonly HashSet _oneCell = new(1); - public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities) { _envCells = envCells; @@ -57,54 +62,103 @@ public sealed class InteriorRenderer public void DrawInside(InteriorRenderContext ctx) { - // Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first). - foreach (uint cellId in ctx.OrderedVisibleCells) + // Retail Loop 2: DrawEnvCell for each drawable cell, farthest-to-nearest + // (cell_draw_list[cell_draw_num - 1] down to 0). + for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--) { - if (!ctx.DrawableCells.Contains(cellId)) continue; // no clip slot ⇒ assembler culled it + uint cellId = ctx.OrderedVisibleCells[i]; + if (!TryBeginCell(ctx, cellId, out _)) continue; _oneCell.Clear(); _oneCell.Add(cellId); + ApplyMembershipOnlyRouting(); _envCells.Render(WbRenderPass.Opaque, _oneCell); - - if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0) - DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell); } - // Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested. - // Drawn AFTER opaque shells so wall depth occludes them correctly. - if (ctx.Partition.LiveDynamic.Count > 0) - DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null); - - // Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces). - foreach (uint cellId in ctx.OrderedVisibleCells) + // Retail Loop 3: Render::PortalList = cell->portal_view; DrawObjCellForDummies(cell). + for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--) { - if (!ctx.DrawableCells.Contains(cellId)) continue; + uint cellId = ctx.OrderedVisibleCells[i]; + if (!TryBeginCell(ctx, cellId, out _)) continue; _oneCell.Clear(); _oneCell.Add(cellId); + if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0) + { + ApplyMembershipOnlyRouting(); + DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell); + } + } + + // Modern split of DrawEnvCell's transparent/additive batches, same reverse cell order. + for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = ctx.OrderedVisibleCells[i]; + if (!TryBeginCell(ctx, cellId, out _)) continue; + _oneCell.Clear(); + _oneCell.Add(cellId); + ApplyMembershipOnlyRouting(); _envCells.Render(WbRenderPass.Transparent, _oneCell); } } + private bool TryBeginCell(InteriorRenderContext ctx, uint cellId, out int[] slots) + { + if (ctx.DrawableCells.Contains(cellId)) + { + ctx.CellClipSlots.TryGetValue(cellId, out slots!); + slots ??= System.Array.Empty(); + return true; + } + + slots = System.Array.Empty(); + return false; + } + + private void ApplyMembershipOnlyRouting() + { + // PView membership controls which cell shell/object bucket is visited. + // Do not turn the 2D portal view into gl_ClipDistance for indoor meshes: + // that slices avatars and shell triangles at stairs/doorways instead of + // matching retail's DrawMesh view-check-then-draw behavior. + _envCells.SetClipRouting(null); + _entities.ClearClipRouting(); + } + // Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry // landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell - // set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0). + // set for per-cell objects; null only for fallback/outdoor buckets where clip-slot routing owns cull). // The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot + // outdoorSlot + outdoorVisible) via ResolveEntitySlot. private void DrawEntityBucket( InteriorRenderContext ctx, IReadOnlyList bucket, HashSet? visibleCellIds) + => DrawEntityBucket( + ctx.Camera, + ctx.Frustum, + ctx.PlayerLandblockId, + ctx.AnimatedEntityIds, + bucket, + visibleCellIds); + + public void DrawEntityBucket( + ICamera camera, + FrustumPlanes? frustum, + uint? playerLandblockId, + HashSet? animatedEntityIds, + IReadOnlyList bucket, + HashSet? visibleCellIds) { // LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is // never landblock-frustum-culled; per-entity AABB culling inside Draw still applies. - uint lbId = ctx.PlayerLandblockId ?? 0u; + uint lbId = playerLandblockId ?? 0u; var entry = (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList)bucket, (IReadOnlyDictionary?)null); _entities.Draw( - ctx.Camera, + camera, new[] { entry }, - ctx.Frustum, - neverCullLandblockId: ctx.PlayerLandblockId, + frustum, + neverCullLandblockId: playerLandblockId, visibleCellIds: visibleCellIds, - animatedEntityIds: ctx.AnimatedEntityIds); + animatedEntityIds: animatedEntityIds); } } diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 61ef0bd5..e47fc338 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -120,7 +120,8 @@ public sealed unsafe class ParticleRenderer : IDisposable ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos, - ParticleRenderPass renderPass = ParticleRenderPass.Scene) + ParticleRenderPass renderPass = ParticleRenderPass.Scene, + Func? emitterFilter = null) { if (particles is null || camera is null) return; @@ -128,7 +129,7 @@ public sealed unsafe class ParticleRenderer : IDisposable Matrix4x4.Invert(camera.View, out var invView); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); - var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp); + var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter); if (draws.Count == 0) return; draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq)); @@ -174,13 +175,16 @@ public sealed unsafe class ParticleRenderer : IDisposable Vector3 cameraWorldPos, ParticleRenderPass renderPass, Vector3 cameraRight, - Vector3 cameraUp) + Vector3 cameraUp, + Func? emitterFilter) { var draws = new List(Math.Max(64, particles.ActiveParticleCount)); foreach (var (em, idx) in particles.EnumerateLive()) { if (em.RenderPass != renderPass) continue; + if (emitterFilter is not null && !emitterFilter(em)) + continue; ref var p = ref em.Particles[idx]; // `p.Position` is already in world coordinates: AttachLocal diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index 53f1c0d1..3250f3c2 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -70,6 +70,117 @@ public static class PortalProjection return ndc; } + /// Faithful homogeneous projection (retail PrimD3DRender::xformStart + the w=0 clip of + /// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip + /// ONLY the eye plane (w >= ), keeping homogeneous coords — NO perspective + /// divide, NO frustum side-plane clamp. The screen bound is applied later by + /// against the view region (the root region is the full screen), exactly as retail clips the portal + /// against the accumulated portal_view rather than fixed side planes. Keeping w means a near/grazing + /// portal never collapses to a zero-area edge sliver (the flap) nor blows up under an early divide + /// (the void). Returns <3 verts when the portal is entirely behind the eye. + public static Vector4[] ProjectToClip(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) + { + if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); + + Matrix4x4 m = cellToWorld * viewProj; + var clip = new List(localPoly.Count); + foreach (var lp in localPoly) + clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); + + // Eye plane ONLY (w >= EyePlaneW), in clip space, homogeneous — no side planes, no divide. + // Retail's polyClipFinish clips at w = 0; EyePlaneW is a hair above 0 so the later divide in + // ClipToRegion never hits the w = 0 singularity. Everything in front of the eye is kept, + // including a portal the camera is standing in (it covers the screen) — the screen bound comes + // from ClipToRegion against the view region, not from a near plane here. + clip = ClipPlane(clip, v => v.W - EyePlaneW); + return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty(); + } + + /// Clip a homogeneous (clip-space) portal polygon against an NDC view region + /// (CCW convex) with w-aware Sutherland-Hodgman edge tests, then divide the survivors to NDC and + /// normalize to CCW. Ports retail ACRender::polyClipFinish's view-region clip (decomp 702749): the + /// edge test multiplies through w (which is > 0 after the eye-plane clip) so it never divides a + /// near-eye vertex, and the final divide runs only on survivors already bounded to the region — + /// stable by construction. Returns <3 verts when the portal does not intersect the region. + public static Vector2[] ClipToRegion(IReadOnlyList subjectClip, IReadOnlyList regionCcwNdc) + { + if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3) + return System.Array.Empty(); + + // Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC + // region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W, + // which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex + // is ever divided (retail polyClipFinish, decomp 702749). + var poly = new List(subjectClip); + int n = regionCcwNdc.Count; + for (int e = 0; e < n; e++) + { + if (poly.Count < 3) return System.Array.Empty(); + poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]); + } + if (poly.Count < 3) return System.Array.Empty(); + + // Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the + // divide is bounded by construction (this is why the homogeneous clip avoids the early-divide + // blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop. + var ndc = new Vector2[poly.Count]; + for (int i = 0; i < poly.Count; i++) + { + float w = poly[i].W; + ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w); + } + EnsureCcw(ndc); + return ndc; + } + + // One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside + // (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross + // product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0. + // Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp. + private static List ClipHomogeneousEdge(List poly, Vector2 a, Vector2 b) + { + var result = new List(poly.Count + 1); + float ex = b.X - a.X, ey = b.Y - a.Y; + for (int i = 0; i < poly.Count; i++) + { + Vector4 cur = poly[i]; + Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; + float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X); + float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X); + bool curIn = dCur >= 0f; + bool prevIn = dPrev >= 0f; + + if (curIn) + { + if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); + result.Add(cur); + } + else if (prevIn) + { + result.Add(Lerp(prev, cur, dPrev, dCur)); + } + } + return result; + } + + // Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's + // EnsureCcw so a clipped region is always CCW for the next hop's ClipToRegion edge test. + private static void EnsureCcw(Vector2[] poly) + { + float area2 = 0f; + for (int i = 0; i < poly.Length; i++) + { + var p = poly[i]; var q = poly[(i + 1) % poly.Length]; + area2 += p.X * q.Y - q.X * p.Y; + } + if (area2 < 0f) System.Array.Reverse(poly); + } + + // Eye plane for the homogeneous clip — a hair above retail's w = 0 so the post-region divide in + // ClipToRegion never divides by zero. Far closer than any near plane: a portal the eye is standing + // in is kept (it covers the screen), so the cell behind it stays visible. + private const float EyePlaneW = 1e-4f; + // Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye // (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is // INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs index 80fa721d..b48a38f9 100644 --- a/src/AcDream.App/Rendering/PortalView.cs +++ b/src/AcDream.App/Rendering/PortalView.cs @@ -5,6 +5,7 @@ // a cell's clip region is a SET of convex polygons in normalized device coords. using System.Collections.Generic; using System.Numerics; +using System.Text; namespace AcDream.App.Rendering; @@ -40,6 +41,10 @@ public readonly struct ViewPolygon public sealed class CellView { public readonly List Polygons = new(); + + // Canonical (snapped) keys of the polygons in , backing the drift-tolerant + // dedup in . One entry per stored polygon; HashSet membership IS the dedup. + private readonly HashSet _polygonKeys = new(); public float MinX { get; private set; } = float.MaxValue; public float MinY { get; private set; } = float.MaxValue; public float MaxX { get; private set; } = float.MinValue; @@ -59,13 +64,82 @@ public sealed class CellView return v; } - public void Add(ViewPolygon p) + public bool Add(ViewPolygon p) { - if (p.IsEmpty) return; + if (p.IsEmpty) return false; + + // Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build + // re-queues a cell every time its CellView GROWS, so the flood only terminates when Add + // recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns + // float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman + + // EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region + // grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its + // vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a + // canonical start. The snapped key space is finite, so a monotonically-growing CellView is + // bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only + // the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub- + // pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge. + string? key = CanonicalKey(p.Vertices); + if (key is null) return false; // degenerate after snap (< 3 distinct vertices) + if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant) + Polygons.Add(p); if (p.MinX < MinX) MinX = p.MinX; if (p.MinY < MinY) MinY = p.MinY; if (p.MaxX > MaxX) MaxX = p.MaxX; if (p.MaxY > MaxY) MaxY = p.MaxY; + return true; + } + + // NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings + // (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped + // region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth. + private const float DedupGridNdc = 1e-3f; + + // Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates + // removed (including wrap-around), then rotated to start at the lexicographically smallest vertex so + // a rotated emission of the same cycle yields the same key. Returns null when fewer than 3 distinct + // snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every + // builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step. + private static string? CanonicalKey(Vector2[]? verts) + { + if (verts is null || verts.Length < 3) return null; + + var pts = new List<(int X, int Y)>(verts.Length); + foreach (var v in verts) + { + var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc)); + if (pts.Count == 0 || pts[^1] != q) pts.Add(q); + } + if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1); + if (pts.Count < 3) return null; + + int n = pts.Count; + int best = 0; + for (int s = 1; s < n; s++) + if (RotationLess(pts, s, best, n)) best = s; + + var sb = new StringBuilder(n * 10); + for (int i = 0; i < n; i++) + { + var q = pts[(best + i) % n]; + sb.Append(q.X).Append(',').Append(q.Y).Append(';'); + } + return sb.ToString(); + } + + // True when the rotation of `pts` starting at index a is lexicographically less than the rotation + // starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical + // start even when two vertices share the minimum snapped coordinate. + private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n) + { + for (int i = 0; i < n; i++) + { + var pa = pts[(a + i) % n]; + var pb = pts[(b + i) % n]; + if (pa.X != pb.X) return pa.X < pb.X; + if (pa.Y != pb.Y) return pa.Y < pb.Y; + } + return false; } } diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index b1fac06b..461d1145 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -37,6 +37,19 @@ public static class PortalVisibilityBuilder { private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon + // Bounded re-enqueue cap (restored 2026-06-07). The distance-priority portal flood re-enqueues a + // cell whenever its accumulated view GROWS, which is load-bearing — it propagates a late-discovered + // portal_view slice to that cell's exit portals (Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit). + // But the faithful near-side clip (ProjectToClip) drifts per round, so re-clipping a cell's view yields + // ever-smaller distinct sub-regions / drifted near-duplicates the dedup can't always collapse -> the + // grow flag never settles and the flood spins forever (the indoor hang). This cap bounds each cell to + // at most this many pops, so the flood terminates in <= N*cap pops regardless of drift while still + // allowing the few re-processes that legitimate late-slice propagation needs. The old hard cap removed + // in U.2a was 4; widened here because ProjectToClip drifts more than the old ProjectToNdc and Option A's + // CellView dedup already collapses most spurious growth, so the cap rarely binds. Tune from the visual + // gate if an interior view under-includes a slice. + private const int MaxReprocessPerCell = 16; + // TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the // local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell. private static readonly bool s_pvDump = @@ -81,7 +94,11 @@ public static class PortalVisibilityBuilder // the instant a cell is popped). Enqueue-once across the cell set is the hard termination // guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The // camera cell is pre-marked so a portal looping back to it can never re-enqueue it. - var seen = new HashSet { cameraCell.CellId }; + var queued = new HashSet { cameraCell.CellId }; + var drawListed = new HashSet(); + var processedViewCounts = new Dictionary(); + var popCounts = new Dictionary(); // per-cell pop count for the MaxReprocessPerCell cap + var trace = PortalBuildTrace.Start(cameraCell, cameraPos); bool pvDump = false; if (s_pvDump) @@ -116,46 +133,81 @@ public static class PortalVisibilityBuilder while (todo.Count > 0) { var cell = todo.PopNearest(); + queued.Remove(cell.CellId); + // Bounded re-enqueue (2026-06-07 termination fix): count this pop. The re-enqueue gate below + // refuses to re-add a cell already popped MaxReprocessPerCell times, so the flood terminates + // even when ProjectToClip drift keeps a view growing forever. Re-enqueue itself is KEPT — it + // propagates late-discovered slices to exit portals (see MaxReprocessPerCell); only its count + // is capped. + popCounts.TryGetValue(cell.CellId, out int popsSoFar); + popCounts[cell.CellId] = popsSoFar + 1; if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) + { + trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view"); continue; + } // `seen` guarantees each cell is inserted into the todo list exactly once, so this single // pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per // pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction. - frame.OrderedVisibleCells.Add(cell.CellId); + if (drawListed.Add(cell.CellId)) + frame.OrderedVisibleCells.Add(cell.CellId); + + processedViewCounts.TryGetValue(cell.CellId, out int processedCount); + int endCount = currentView.Polygons.Count; + if (processedCount >= endCount) + { + trace?.Add($"pop cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}"); + continue; + } + trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}"); + + var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount); + processedViewCounts[cell.CellId] = endCount; for (int i = 0; i < cell.Portals.Count; i++) { - if (i >= cell.PortalPolygons.Count) continue; + var portal = cell.Portals[i]; + if (i >= cell.PortalPolygons.Count) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot"); + continue; + } var poly = cell.PortalPolygons[i]; - if (poly == null || poly.Length < 3) continue; + if (poly == null || poly.Length < 3) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}"); + continue; + } bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF; + bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); + bool sideAllowed = true; // Portal-side test: only traverse a portal the camera is on the interior side of // (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing // portals so we never feed a degenerate/wrong-facing projection downstream. - if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos)) + if (i < cell.ClipPlanes.Count + && !CameraOnInteriorSide(cell, i, cameraPos) + && !eyeInsideOpening) { + sideAllowed = false; + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}"); if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}"); continue; } - // Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip - // (ProjectToNdc preserves input winding; portal dat polygons may be CW). - Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj); - if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]"); - var clippedRegion = new List(); - if (portalNdc.Length >= 3) - { - EnsureCcw(portalNdc); - // Intersect the portal opening with every polygon of the current cell's view. - foreach (var vp in currentView.Polygons) - { - var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices); - if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); - } - } + // Retail PView::ClipPortals calls GetClip(..., finish=1): transform to + // homogeneous clip space, clip at the eye, then clip against the current + // portal_view region before the divide. Do the same here; the old early + // ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways. + var clippedRegion = ClipPortalAgainstView( + poly, + cell.WorldTransform, + viewProj, + activeViewPolygons, + out int clipVerts); + if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})"); if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); // R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the @@ -171,25 +223,26 @@ public static class PortalVisibilityBuilder if (clippedRegion.Count == 0) { if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}"); continue; // portal not visible through this chain, and the eye is not standing in it - foreach (var vp in currentView.Polygons) + } + foreach (var vp in activeViewPolygons) clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone())); } - var portal = cell.Portals[i]; - if (portal.OtherCellId == 0xFFFF) { if (pvDump) { - Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}"); + Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}"); Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]"); - Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]"); foreach (var cp in clippedRegion) Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]"); } // Exit portal -> outdoors visible through this (clipped) opening. - foreach (var cp in clippedRegion) frame.OutsideView.Add(cp); + AddRegion(frame.OutsideView, clippedRegion); + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}"); continue; } @@ -202,12 +255,17 @@ public static class PortalVisibilityBuilder if (buildingMembership != null && !buildingMembership(neighbourId)) { var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId); - foreach (var cp in clippedRegion) xview.Add(cp); + bool grewCross = AddRegion(xview, clippedRegion); + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}"); continue; } var neighbour = lookup(neighbourId); - if (neighbour == null) continue; + if (neighbour == null) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}"); + continue; + } // Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip // decomp:433524). The portal opening seen from THIS cell may be wider than the @@ -222,12 +280,24 @@ public static class PortalVisibilityBuilder // direct index is what lets a cell with TWO portals to the same neighbour clip each // opening against its OWN reciprocal instead of the first one. Mutates clippedRegion // in place before the union below. + var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; + int preReciprocalCount = clippedRegion.Count; ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj); - if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible + if (clippedRegion.Count == 0) + { + if (preReciprocalClip is null) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}"); + continue; + } + clippedRegion.AddRange(preReciprocalClip); + } // Union the clipped region into the neighbour's accumulated view. var nview = GetOrCreate(frame.CellViews, neighbourId); - foreach (var cp in clippedRegion) nview.Add(cp); + bool grew = AddRegion(nview, clippedRegion); + bool inserted = false; + float dist = float.NaN; // Insert the neighbour into the distance-priority list — but ONLY on first discovery // (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an @@ -237,11 +307,13 @@ public static class PortalVisibilityBuilder // portal-opening vertex in world space (retail InitCell min-vertex distance, // 432988-433004); derived from the portal geometry, so it works even when the cell's // WorldPosition was never populated. - if (seen.Add(neighbourId)) + if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) { - float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); + dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); todo.Insert(neighbour, dist); + inserted = true; } + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}"); } } @@ -252,6 +324,161 @@ public static class PortalVisibilityBuilder // root cell's per-portal side-test + projection + the frame's exit/visible counts. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) EmitFlapProbe(cameraCell, cameraPos, viewProj, frame); + trace?.Emit(frame); + + return frame; + } + + /// + /// Build a portal visibility frame for an OUTDOOR viewer looking into one or more + /// outside-facing cell portals. This is the reciprocal of : + /// the seed view is the projected exit-portal opening instead of a full-screen + /// camera cell. It keeps the same retail distance-priority traversal and + /// neighbour reciprocal clipping once inside the building. + /// + public static PortalVisibilityFrame BuildFromExterior( + IEnumerable candidateCells, + Vector3 cameraPos, + Func lookup, + Matrix4x4 viewProj, + float maxSeedDistance = float.PositiveInfinity) + { + var frame = new PortalVisibilityFrame(); + var todo = new CellTodoList(); + var queued = new HashSet(); + var drawListed = new HashSet(); + var processedViewCounts = new Dictionary(); + var popCounts = new Dictionary(); // per-cell pop count for the MaxReprocessPerCell cap + + foreach (var cell in candidateCells) + { + if (cell is null) continue; + + for (int i = 0; i < cell.Portals.Count; i++) + { + var portal = cell.Portals[i]; + if (portal.OtherCellId != 0xFFFF) + continue; + if (i >= cell.PortalPolygons.Count) + continue; + + var poly = cell.PortalPolygons[i]; + if (poly == null || poly.Length < 3) + continue; + + // Exterior peering starts from the OUTSIDE face of an exit portal. + // If the camera is on the cell-interior side, the normal indoor + // DrawInside path owns this portal instead. + if (i < cell.ClipPlanes.Count && CameraOnInteriorSide(cell, i, cameraPos)) + continue; + + float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); + if (seedDistance > maxSeedDistance) + continue; + + var clippedRegion = ClipPortalAgainstView( + poly, + cell.WorldTransform, + viewProj, + FullScreenRegion, + out _); + + if (clippedRegion.Count == 0) + { + if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) + continue; + clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone())); + } + + var seedView = GetOrCreate(frame.CellViews, cell.CellId); + bool grew = AddRegion(seedView, clippedRegion); + + if (grew && queued.Add(cell.CellId)) + todo.Insert(cell, seedDistance); + } + } + + while (todo.Count > 0) + { + var cell = todo.PopNearest(); + queued.Remove(cell.CellId); + // Bounded re-enqueue — see the matching note in Build(). Count this pop; the gate below caps + // re-enqueues at MaxReprocessPerCell so the look-in flood terminates under ProjectToClip drift. + popCounts.TryGetValue(cell.CellId, out int popsSoFar); + popCounts[cell.CellId] = popsSoFar + 1; + if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) + continue; + + if (drawListed.Add(cell.CellId)) + frame.OrderedVisibleCells.Add(cell.CellId); + + processedViewCounts.TryGetValue(cell.CellId, out int processedCount); + int endCount = currentView.Polygons.Count; + if (processedCount >= endCount) + continue; + + var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount); + processedViewCounts[cell.CellId] = endCount; + uint lbMask = cell.CellId & 0xFFFF0000u; + + for (int i = 0; i < cell.Portals.Count; i++) + { + if (i >= cell.PortalPolygons.Count) + continue; + + var poly = cell.PortalPolygons[i]; + if (poly == null || poly.Length < 3) + continue; + + var portal = cell.Portals[i]; + if (portal.OtherCellId == 0xFFFF) + continue; // already outdoors; exterior terrain was drawn by the caller. + + bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); + if (i < cell.ClipPlanes.Count + && !CameraOnInteriorSide(cell, i, cameraPos) + && !eyeInsideOpening) + continue; + + var clippedRegion = ClipPortalAgainstView( + poly, + cell.WorldTransform, + viewProj, + activeViewPolygons, + out _); + + if (clippedRegion.Count == 0) + { + if (!eyeInsideOpening) + continue; + foreach (var vp in activeViewPolygons) + clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone())); + } + + uint neighbourId = lbMask | portal.OtherCellId; + var neighbour = lookup(neighbourId); + if (neighbour == null) + continue; + + var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; + ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj); + if (clippedRegion.Count == 0) + { + if (preReciprocalClip is null) + continue; + clippedRegion.AddRange(preReciprocalClip); + } + + var nview = GetOrCreate(frame.CellViews, neighbourId); + bool grew = AddRegion(nview, clippedRegion); + + if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) + { + float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); + todo.Insert(neighbour, dist); + } + } + } return frame; } @@ -260,6 +487,117 @@ public static class PortalVisibilityBuilder private static readonly Vector2[] FullScreenQuad = { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) }; + private static readonly ViewPolygon[] FullScreenRegion = + { new ViewPolygon(FullScreenQuad) }; + + private static List ClipPortalAgainstView( + Vector3[] localPoly, + Matrix4x4 cellToWorld, + Matrix4x4 viewProj, + IReadOnlyList viewPolygons, + out int clipVertexCount) + { + var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj); + clipVertexCount = portalClip.Length; + var clippedRegion = new List(); + if (portalClip.Length < 3) + return clippedRegion; + + foreach (var vp in viewPolygons) + { + if (vp.IsEmpty) + continue; + + var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices); + if (clipped.Length >= 3) + clippedRegion.Add(new ViewPolygon(clipped)); + } + + return clippedRegion; + } + + private const int PortalTraceEmitLimit = 160; + private static readonly object s_portalTraceLock = new(); + private static readonly Dictionary s_portalTraceLastSignature = new(); + private static int s_portalTraceEmits; + + private sealed class PortalBuildTrace + { + private readonly uint _rootCellId; + private readonly Vector3 _eye; + private readonly List _lines = new(); + + private PortalBuildTrace(uint rootCellId, Vector3 eye) + { + _rootCellId = rootCellId; + _eye = eye; + } + + public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye) + { + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + return null; + if (!IsHoltburgIndoorProbeCell(root.CellId)) + return null; + return new PortalBuildTrace(root.CellId, eye); + } + + public void Add(string line) + { + if (_lines.Count < 96) + _lines.Add(line); + } + + public void Emit(PortalVisibilityFrame frame) + { + string signature = BuildSignature(frame); + lock (s_portalTraceLock) + { + if (s_portalTraceEmits >= PortalTraceEmitLimit) + return; + if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) && + string.Equals(last, signature, StringComparison.Ordinal)) + return; + s_portalTraceLastSignature[_rootCellId] = signature; + s_portalTraceEmits++; + } + + Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}"); + foreach (var line in _lines) + Console.WriteLine("[pv-trace] " + line); + } + } + + private static bool IsHoltburgIndoorProbeCell(uint cellId) + { + if ((cellId & 0xFFFF0000u) != 0xA9B40000u) + return false; + uint low = cellId & 0xFFFFu; + return low >= 0x016F && low <= 0x0175; + } + + private static string BuildSignature(PortalVisibilityFrame frame) + { + var sb = new System.Text.StringBuilder(160); + sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count); + sb.Append(" cells=["); + for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) + { + if (i != 0) sb.Append(','); + sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4")); + } + sb.Append("] views=["); + bool first = true; + foreach (var kvp in frame.CellViews) + { + if (!first) sb.Append(','); + first = false; + sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count); + } + sb.Append(']'); + return sb.ToString(); + } + // Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal // signed distance D (eye→portal plane), traverse/cull decision, and NDC projection // vertex count, plus the frame's OutsideView polygon count + visible-cell count. @@ -288,10 +626,10 @@ public static class PortalVisibilityBuilder d = Vector3.Dot(pl.Normal, localEye) + pl.D; side = CameraOnInteriorSide(cameraCell, i, cameraPos); } - // Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a - // portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY: - // clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with - // ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie. + // Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so + // proj/clip mean the same as production: proj = clip-space verts in front of the eye, + // clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is + // genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands. int projN = -1, clipN = -1; string ndcText = ""; if (i < cameraCell.PortalPolygons.Count) @@ -299,12 +637,12 @@ public static class PortalVisibilityBuilder var poly = cameraCell.PortalPolygons[i]; if (poly != null && poly.Length >= 3) { - var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj); - projN = ndc.Length; - if (ndc.Length >= 3) + var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj); + projN = clip.Length; + if (clip.Length >= 3) { - EnsureCcw(ndc); - clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length; + var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad); + clipN = ndc.Length; var ns = new System.Text.StringBuilder(48); foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')'); ndcText = ns.ToString(); @@ -376,6 +714,13 @@ public static class PortalVisibilityBuilder // Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3, // &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper. + // NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a + // back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here, + // and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through + // ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView + // SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area + // (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the + // homogeneous path; this secondary tightening is not. Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj); if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op EnsureCcw(reciprocalNdc); @@ -395,11 +740,27 @@ public static class PortalVisibilityBuilder return v; } + private static bool AddRegion(CellView view, List region) + { + bool grew = false; + foreach (var poly in region) + grew |= view.Add(poly); + return grew; + } + // Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal // min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list: // it walks the portal's vertices, transforms each to world space, and keeps the smallest // straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell // origin) is both retail-faithful and robust to cells whose WorldPosition was never populated. + private static List CloneViewPolygons(List source) + { + var clone = new List(source.Count); + foreach (var poly in source) + clone.Add(new ViewPolygon((Vector2[])poly.Vertices.Clone())); + return clone; + } + private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos) { float best = float.MaxValue; @@ -413,10 +774,11 @@ public static class PortalVisibilityBuilder } // "Eye standing in the opening": the eye is within this perpendicular distance of a portal's - // plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase - // camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals - // the eye is merely facing from across a room (their projection is non-degenerate anyway). - private const float EyeStandingPerpDist = 0.5f; + // plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and + // cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still + // require the perpendicular projection to land inside the opening, so side/offscreen portals stay + // culled; this only covers active portals whose 2D projection collapses near the chase camera. + private const float EyeStandingPerpDist = 1.75f; /// /// True when the camera eye is "standing in" 's opening: within diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs new file mode 100644 index 00000000..024ec3d9 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// App-layer port of the retail indoor render orchestration: +/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside -> +/// PView::DrawInside -> ConstructView -> DrawCells. +/// +public sealed class RetailPViewRenderer +{ + private readonly GL _gl; + private readonly ClipFrame _clipFrame; + private readonly EnvCellRenderer _envCells; + private readonly WbDrawDispatcher _entities; + + private static readonly ClipViewSlice NoClipSlice = + new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); + + private readonly HashSet _oneCell = new(1); + private readonly Dictionary _oneCellSlot = new(1); + public RetailPViewRenderer( + GL gl, + ClipFrame clipFrame, + EnvCellRenderer envCells, + WbDrawDispatcher entities) + { + _gl = gl; + _clipFrame = clipFrame; + _envCells = envCells; + _entities = entities; + } + + public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + + var pvFrame = PortalVisibilityBuilder.Build( + ctx.RootCell, + ctx.ViewerEyePos, + ctx.CellLookup, + ctx.ViewProjection); + + var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); + UploadClipFrame(ctx.SetTerrainClipUbo); + + // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the + // assembler handed a clip-slot. This feeds the Prepare filter + entity partition, + // so every visible cell's shell has a prepared batch and seals — killing the grey + // (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells). + // Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained). + var drawableCells = new HashSet(pvFrame.OrderedVisibleCells); + UseIndoorMembershipOnlyRouting(); + + _envCells.PrepareRenderBatches( + ctx.ViewProjection, + ctx.CameraWorldPosition, + filter: drawableCells, + centerLbX: ctx.RenderCenterLbX, + centerLbY: ctx.RenderCenterLbY, + renderRadius: ctx.RenderRadius); + + var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries); + var result = new RetailPViewFrameResult + { + PortalFrame = pvFrame, + ClipAssembly = clipAssembly, + DrawableCells = drawableCells, + Partition = partition, + }; + + ctx.EmitDiagnostics?.Invoke(result); + + DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition); + UseIndoorMembershipOnlyRouting(); + DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + + return result; + } + + public RetailPViewFrameResult? DrawPortal(RetailPViewPortalDrawContext ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + + var pvFrame = PortalVisibilityBuilder.BuildFromExterior( + ctx.CandidateCells, + ctx.ViewerEyePos, + ctx.CellLookup, + ctx.ViewProjection, + ctx.MaxSeedDistance); + + if (pvFrame.OrderedVisibleCells.Count == 0) + { + RestoreNoClip(ctx.SetTerrainClipUbo); + return null; + } + + var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); + UploadClipFrame(ctx.SetTerrainClipUbo); + + var drawableCells = new HashSet(clipAssembly.CellIdToSlot.Keys); + UseIndoorMembershipOnlyRouting(); + + _envCells.PrepareRenderBatches( + ctx.ViewProjection, + ctx.CameraWorldPosition, + filter: drawableCells, + centerLbX: ctx.RenderCenterLbX, + centerLbY: ctx.RenderCenterLbY, + renderRadius: ctx.RenderRadius); + + var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries); + var result = new RetailPViewFrameResult + { + PortalFrame = pvFrame, + ClipAssembly = clipAssembly, + DrawableCells = drawableCells, + Partition = partition, + }; + + ctx.EmitDiagnostics?.Invoke(result); + + DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + RestoreNoClip(ctx.SetTerrainClipUbo); + + return result; + } + + private void DrawLandscapeThroughOutsideView( + RetailPViewDrawContext ctx, + ClipFrameAssembly clipAssembly, + InteriorEntityPartition.Result partition) + { + if (clipAssembly.OutsideViewSlices.Length == 0) + return; + + foreach (var slice in clipAssembly.OutsideViewSlices) + { + _clipFrame.SetTerrainClip(slice.Planes); + UploadClipFrame(ctx.SetTerrainClipUbo); + _entities.SetClipRouting(clipAssembly.CellIdToSlot, slice.Slot, outdoorVisible: true); + ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor)); + } + + foreach (var slice in clipAssembly.OutsideViewSlices) + ctx.ClearDepthSlice?.Invoke(slice); + + UseIndoorMembershipOnlyRouting(); + } + + private void DrawExitPortalMasks( + IRetailPViewCellDrawCallbacks ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells) + { + if (ctx.DrawExitPortalMasks is null) + return; + + for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = pvFrame.OrderedVisibleCells[i]; + if (!drawableCells.Contains(cellId)) + continue; + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); + } + } + + private void DrawEnvCellShells( + IRetailPViewCellDrawCallbacks ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells) // param kept this task; removed in Task 4 + { + // Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list + // (far→near), per portal_view slice. No drawableCells filter — a cell without a + // clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped + // (sealed; per-slice trim returns in Task 4). + foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) + { + uint cellId = entry.CellId; + _oneCell.Clear(); + _oneCell.Add(cellId); + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + { + UseShellClipRouting(cellId, slice); + _envCells.Render(WbRenderPass.Opaque, _oneCell); + _envCells.Render(WbRenderPass.Transparent, _oneCell); + } + } + } + + private void DrawCellObjectLists( + IRetailPViewCellDrawContext ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells, + InteriorEntityPartition.Result partition) + { + for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = pvFrame.OrderedVisibleCells[i]; + if (!drawableCells.Contains(cellId)) + continue; + + if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) + continue; + + _oneCell.Clear(); + _oneCell.Add(cellId); + + UseIndoorMembershipOnlyRouting(); + DrawEntityBucket(ctx, bucket, _oneCell); + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket)); + } + } + + private static ClipViewSlice[] GetCellSlicesOrNoClip( + ClipFrameAssembly clipAssembly, + uint cellId) + { + if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices) + && slices.Length > 0) + return slices; + + return new[] { NoClipSlice }; + } + + private void UseIndoorMembershipOnlyRouting() + { + // Retail's PView portal views decide which cells/objects are eligible, + // but DrawMesh only performs portal-view visibility checks before drawing + // the mesh. Feeding those 2D views into gl_ClipDistance slices characters + // and cell shells at stair/door boundaries, which retail does not do. + _envCells.SetClipRouting(null); + _entities.ClearClipRouting(); + } + + private void UseShellClipRouting(uint cellId, ClipViewSlice slice) + { + _oneCellSlot.Clear(); + _oneCellSlot[cellId] = slice.Slot; + _envCells.SetClipRouting(_oneCellSlot); + _entities.ClearClipRouting(); + } + + private void DrawEntityBucket( + IRetailPViewCellDrawContext ctx, + IReadOnlyList bucket, + HashSet? visibleCellIds) + { + uint lbId = ctx.PlayerLandblockId ?? 0u; + var entry = (lbId, Vector3.Zero, Vector3.Zero, + (IReadOnlyList)bucket, + (IReadOnlyDictionary?)null); + + _entities.Draw( + ctx.Camera, + new[] { entry }, + ctx.Frustum, + neverCullLandblockId: ctx.PlayerLandblockId, + visibleCellIds: visibleCellIds, + animatedEntityIds: ctx.AnimatedEntityIds); + } + + private void RestoreNoClip(Action setTerrainClipUbo) + { + _clipFrame.Reset(); + UploadClipFrame(setTerrainClipUbo); + UseIndoorMembershipOnlyRouting(); + } + + private void UploadClipFrame(Action setTerrainClipUbo) + { + _clipFrame.UploadShared(_gl); + _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); + setTerrainClipUbo(_clipFrame.TerrainUbo); + } +} + +public interface IRetailPViewCellDrawCallbacks +{ + public Action? DrawExitPortalMasks { get; } + public Action? DrawCellParticles { get; } +} + +public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks +{ + public ICamera Camera { get; } + public FrustumPlanes? Frustum { get; } + public uint? PlayerLandblockId { get; } + public HashSet? AnimatedEntityIds { get; } +} + +public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext +{ + public required LoadedCell RootCell { get; init; } + public required Vector3 ViewerEyePos { get; init; } + public required Matrix4x4 ViewProjection { get; init; } + public required Func CellLookup { get; init; } + public required ICamera Camera { get; init; } + public required Vector3 CameraWorldPosition { get; init; } + public required FrustumPlanes? Frustum { get; init; } + public required uint? PlayerLandblockId { get; init; } + public required HashSet? AnimatedEntityIds { get; init; } + public required int RenderCenterLbX { get; init; } + public required int RenderCenterLbY { get; init; } + public required int RenderRadius { get; init; } + public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } + public required Action SetTerrainClipUbo { get; init; } + public required Action DrawLandscapeSlice { get; init; } + public Action? ClearDepthSlice { get; init; } + public Action? DrawExitPortalMasks { get; init; } + public Action? DrawCellParticles { get; init; } + public Action? EmitDiagnostics { get; init; } +} + +public sealed class RetailPViewPortalDrawContext : IRetailPViewCellDrawContext +{ + public required IEnumerable CandidateCells { get; init; } + public required Vector3 ViewerEyePos { get; init; } + public required Matrix4x4 ViewProjection { get; init; } + public required Func CellLookup { get; init; } + public required ICamera Camera { get; init; } + public required Vector3 CameraWorldPosition { get; init; } + public required FrustumPlanes? Frustum { get; init; } + public required uint? PlayerLandblockId { get; init; } + public required HashSet? AnimatedEntityIds { get; init; } + public required int RenderCenterLbX { get; init; } + public required int RenderCenterLbY { get; init; } + public required int RenderRadius { get; init; } + public required float MaxSeedDistance { get; init; } + public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } + public required Action SetTerrainClipUbo { get; init; } + public Action? DrawExitPortalMasks { get; init; } + public Action? DrawCellParticles { get; init; } + public Action? EmitDiagnostics { get; init; } +} + +public sealed class RetailPViewFrameResult +{ + public required PortalVisibilityFrame PortalFrame { get; init; } + public required ClipFrameAssembly ClipAssembly { get; init; } + public required HashSet DrawableCells { get; init; } + public required InteriorEntityPartition.Result Partition { get; init; } +} + +public readonly record struct RetailPViewLandscapeSliceContext( + ClipViewSlice Slice, + IReadOnlyList OutdoorEntities); + +public readonly record struct RetailPViewCellSliceContext( + uint CellId, + ClipViewSlice Slice, + IReadOnlyList CellEntities); diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index fa6f225b..ddb819c5 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -1291,21 +1291,24 @@ namespace AcDream.App.Rendering.Wb { ct.ThrowIfCancellationRequested(); if (poly.VertexIds.Count < 3) continue; - // Handle Positive Surface - if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { - AddSurfaceToBatch(poly, poly.PosSurface, false); + // Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this + // DatReaderWriter "CullMode" as CPolygon::sides_type, not as a + // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, + // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still + // suppress hidden portal/cap faces before they reach our mesh. + bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); + + if (hasPos) + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false); + if (hasPos && poly.SidesType == CullMode.None) { + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true); + } + else if (hasNeg && poly.SidesType == CullMode.Clockwise) { + AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false); } - // Handle Negative Surface - bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) || - poly.Stippling.HasFlag(StipplingType.Both) || - (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); - - if (hasNeg) { - AddSurfaceToBatch(poly, poly.NegSurface, true); - } - - void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) { + void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) { if (surfaceIdx < 0) return; uint surfaceId; @@ -1499,7 +1502,17 @@ namespace AcDream.App.Rendering.Wb { // Helper for CellStruct vertices bool batchHasWrappingUVs = batch.HasWrappingUVs; - BuildCellStructPolygonIndices(poly, cellStruct, UVLookup, vertices, batch.Indices, isNeg, transform, ref batchHasWrappingUVs); + BuildCellStructPolygonIndices( + poly, + cellStruct, + UVLookup, + vertices, + batch.Indices, + useNegUv, + invertNormal, + reverseWinding, + transform, + ref batchHasWrappingUVs); batch.HasWrappingUVs = batchHasWrappingUVs; } } @@ -1516,8 +1529,10 @@ namespace AcDream.App.Rendering.Wb { } private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, - Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, - List vertices, List indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) { + Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup, + List vertices, List indices, + bool useNegUv, bool invertNormal, bool reverseWinding, + Matrix4x4 transform, ref bool hasWrappingUVs) { var polyIndices = new List(); @@ -1525,9 +1540,9 @@ namespace AcDream.App.Rendering.Wb { ushort vertId = (ushort)poly.VertexIds[i]; ushort uvIdx = 0; - if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) uvIdx = poly.NegUVIndices[i]; - else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count) uvIdx = poly.PosUVIndices[i]; if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; @@ -1536,7 +1551,7 @@ namespace AcDream.App.Rendering.Wb { uvIdx = 0; } - var key = (vertId, uvIdx, useNegSurface); + var key = (vertId, uvIdx, invertNormal); if (!hasWrappingUVs) { var uvCheck = vertex.UVs.Count > 0 @@ -1553,7 +1568,7 @@ namespace AcDream.App.Rendering.Wb { : Vector2.Zero; var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform)); - if (useNegSurface) { + if (invertNormal) { normal = -normal; } @@ -1568,18 +1583,18 @@ namespace AcDream.App.Rendering.Wb { polyIndices.Add(idx); } - if (useNegSurface) { + if (reverseWinding) { for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[0]); - indices.Add(polyIndices[i - 1]); indices.Add(polyIndices[i]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[0]); } } else { for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[i]); - indices.Add(polyIndices[i - 1]); indices.Add(polyIndices[0]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[i]); } } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 5efa8008..c2f3fbba 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -144,8 +144,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root / // no portal frame), every instance maps to slot 0 (no-clip) and no instance is // culled — identical to U.3. When active, each instance's slot is resolved by - // ResolveEntitySlot per the U.4 policy (live-dynamic unclipped; cell statics to - // their cell slot; outdoor scenery to the OutsideView slot; non-visible culled). + // ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot; + // outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled). private bool _clipRoutingActive; private IReadOnlyDictionary? _cellIdToSlot; private int _outdoorSlot; @@ -310,8 +310,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// Phase U.4: install the per-frame clip-slot routing for an INDOOR root. /// Call once per frame BEFORE when the camera's root cell is /// non-null; the next resolves each instance's binding=3 - /// clip slot via the U.4 policy (live-dynamic unclipped, cell statics to their - /// cell slot, outdoor scenery to the OutsideView slot, non-visible culled). + /// clip slot via the U.4 policy (cell-owned entities to their cell slot, + /// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled). /// Pair with on outdoor-root frames so the /// dispatcher reverts to the U.3 no-clip-everything behavior. /// @@ -354,12 +354,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// Phase U.4: resolve the clip slot for one entity per the slot/gate policy. /// Returns to drop the entity's instances entirely. /// - /// ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0 - /// (UNCLIPPED — retail draws live-dynamic unclipped; depth only). - /// ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the - /// cell isn't in (not visible / nothing-visible). - /// ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView - /// slot when , else CULL. + /// Indoor ParentCellId: the cell's slot, or CULL when hidden. + /// Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot + /// when , else CULL. + /// ServerGuid != 0 with ParentCellId == null: CULL while routing is active. /// /// Only called when _clipRoutingActive (indoor root). On the U.3 / outdoor /// path every instance is slot 0 and nothing is culled — see @@ -385,20 +383,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable int outdoorSlot, bool outdoorVisible) { - // Live-dynamic entities render unclipped regardless of cell — retail draws - // the player / NPCs / dropped items through the depth buffer without portal - // clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated). - if (serverGuid != 0) - return 0; - + // Live-dynamic entities are not a global indoor overlay. When they + // have current cell ownership, route them through the same visible + // cell/OutsideView graph as every other object. Parentless live objects + // are unresolved indoors, so cull them while clip routing is active. if (parentCellId is uint parentCell) - return cellIdToSlot.TryGetValue(parentCell, out int slot) ? slot : ClipSlotCull; + { + if (IsIndoorCellId(parentCell)) + { + if (!cellIdToSlot.ContainsKey(parentCell)) + return ClipSlotCull; + + return cellIdToSlot[parentCell]; + } + + return outdoorVisible ? outdoorSlot : ClipSlotCull; + } + + if (serverGuid != 0) + return ClipSlotCull; // Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to // the OutsideView slot, or cull when nothing outdoors is visible. return outdoorVisible ? outdoorSlot : ClipSlotCull; } + private static bool IsIndoorCellId(uint cellId) + { + uint low = cellId & 0xFFFFu; + return low >= 0x0100u && low != 0xFFFFu; + } + /// /// Phase U.4: the call-site clip-slot decision for one entity, returning the /// (Slot, Culled) pair the per-entity loop body consumes. Wraps diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index e44a920f..ad6b7793 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -280,9 +280,9 @@ public static class RenderingDiagnostics /// DrawInside vs the outdoor LScape::draw on is_player_outside — the /// PLAYER's cell ((player->m_position.objcell_id & 0xFFFF) < 0x100, /// SmartBox::is_player_outside 0x451e80) — NOT the camera/viewer cell. When the - /// player is inside it then roots the flood at the viewer cell - /// (this->viewer_cell). So the inside/outside decision follows the player; - /// only the indoor root follows the camera. + /// player is inside, acdream roots the portal flood at the player's transition-owned + /// physics cell and projects from the camera eye, so the shell around the player remains + /// sealed during chase-camera cell transitions. /// /// acdream historically branched on the camera cell (a non-null /// visibility.CameraCell). A 3rd-person chase camera lags the player, so when the @@ -292,9 +292,9 @@ public static class RenderingDiagnostics /// only entities (which bypass the gate) showing through. Branching on the player removes it. /// /// The player's current cell id (0 if unresolved → outside). - /// Whether a viewer/camera cell is available to root - /// DrawInside at. Indoor render needs both: the player inside AND a cell to root at. + /// Whether the player's indoor render root is loaded and + /// available to DrawInside. /// - public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved) - => viewerCellResolved && IsEnvCellId(playerCellId); + public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved) + => renderRootResolved && IsEnvCellId(playerCellId); } diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index e4fa670d..6121720a 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -37,12 +37,13 @@ public sealed class WorldEntity public PaletteOverride? PaletteOverride { get; init; } /// - /// EnvCell ID that owns this entity (room geometry or static object inside + /// EnvCell or outdoor cell ID that owns this entity (room geometry, static + /// object, or live object inside/outside a cell). /// the cell). Used by portal visibility to filter interior entities — only /// entities whose ParentCellId appears in the visible set are rendered. - /// Null for outdoor entities (stabs, scenery, live server spawns). + /// Null for outdoor dat scenery/building stabs or unresolved live entities. /// - public uint? ParentCellId { get; init; } + public uint? ParentCellId { get; set; } /// /// True when this entity originates from LandBlockInfo.Buildings[] diff --git a/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs b/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs new file mode 100644 index 00000000..1e545007 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +// Regression tests for the indoor-render HANG (2026-06-06): the portal-visibility flood +// re-queues a cell whenever its CellView grows, so it only terminates when CellView.Add's +// dedup catches a duplicate. Across BFS rounds the same region comes back float-drifted, +// vertex-rotated, or with a ±1 vertex count; the old exact index-by-index SamePolygon +// (eps 1e-4) missed all three, so the region grew forever -> CPU-spin hang in CellView.Add. +// A drift-tolerant, rotation-invariant dedup makes the key space finite, so the flood +// converges (and these duplicates collapse). +public class CellViewDedupTests +{ + private static ViewPolygon Quad(float ox, float oy) => new(new[] + { + new Vector2(ox - 0.5f, oy - 0.5f), new Vector2(ox + 0.5f, oy - 0.5f), + new Vector2(ox + 0.5f, oy + 0.5f), new Vector2(ox - 0.5f, oy + 0.5f), + }); + + [Fact] + public void Add_DropsSubGridDriftDuplicate() + { + var v = new CellView(); + Assert.True(v.Add(Quad(0f, 0f))); + // Same quad, every vertex nudged 3e-4 — beyond the old 1e-4 SamePolygon eps, + // within the 1e-3 dedup grid. This is the per-round float drift that caused the hang. + var drifted = new ViewPolygon(new[] + { + new Vector2(-0.5f + 3e-4f, -0.5f - 3e-4f), new Vector2(0.5f + 3e-4f, -0.5f - 3e-4f), + new Vector2(0.5f + 3e-4f, 0.5f - 3e-4f), new Vector2(-0.5f + 3e-4f, 0.5f - 3e-4f), + }); + Assert.False(v.Add(drifted)); + Assert.Single(v.Polygons); + } + + [Fact] + public void Add_DropsRotatedStartDuplicate() + { + var v = new CellView(); + Assert.True(v.Add(Quad(0f, 0f))); + // Same 4 corners (same CCW cycle), but emitted starting at the 2nd vertex — what + // Sutherland-Hodgman can do when the subject order differs across rounds. + var rotated = new ViewPolygon(new[] + { + new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f), + new Vector2(-0.5f, 0.5f), new Vector2(-0.5f, -0.5f), + }); + Assert.False(v.Add(rotated)); + Assert.Single(v.Polygons); + } + + [Fact] + public void Add_KeepsGenuinelyDistinctPolygons() + { + // The fix must NOT over-merge: two regions 0.4 NDC apart (far beyond the 1e-3 grid) + // remain distinct, so a real second portal opening is not silently dropped. + var v = new CellView(); + Assert.True(v.Add(Quad(0f, 0f))); + Assert.True(v.Add(Quad(0.4f, 0f))); + Assert.Equal(2, v.Polygons.Count); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs index 1f1cbfed..8962529a 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs @@ -5,36 +5,27 @@ using Xunit; namespace AcDream.App.Tests.Rendering; -/// -/// Phase U.4: GL-free proof that implements the -/// slot/gate policy — slot 0 no-clip, one CellClip slot per visible cell with a -/// convex region, OutsideView routed to the terrain decision + the outdoor mesh -/// slot, and the three Count==0 dispositions (nothing-visible cull, scissor -/// fallback → no-clip, planes). Hand-built s -/// drive the assembler directly (no portal BFS needed) so each disposition is -/// exercised in isolation. -/// public class ClipFrameAssemblerTests { - // A convex NDC square → ClipPlaneSet.From yields 4 planes (Count > 0). private static ViewPolygon Square(float cx, float cy, float half) => new(new[] { - new Vector2(cx - half, cy - half), new Vector2(cx + half, cy - half), - new Vector2(cx + half, cy + half), new Vector2(cx - half, cy + half), + new Vector2(cx - half, cy - half), + new Vector2(cx + half, cy - half), + new Vector2(cx + half, cy + half), + new Vector2(cx - half, cy + half), }); private static CellView ViewOf(params ViewPolygon[] polys) { - var v = new CellView(); - foreach (var p in polys) v.Add(p); - return v; + var view = new CellView(); + foreach (var p in polys) + view.Add(p); + return view; } [Fact] public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts() { - // Two cells with single convex regions (→ planes, mapped to slots 1 and 2) - // and a single-convex OutsideView (→ planes, the outdoor slot 3). const uint cellA = 0xA9B40100; const uint cellB = 0xA9B40101; @@ -45,199 +36,154 @@ public class ClipFrameAssemblerTests pv.OrderedVisibleCells.Add(cellB); pv.OutsideView.Add(Square(0f, 0.5f, 0.25f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - // slot 0 reserved (no-clip) + 2 cells + 1 outdoor = 4 slots. - Assert.Equal(4, asm.Frame.SlotCount); - - // Both cells mapped to NON-zero slots (real plane regions), distinct. - Assert.True(asm.CellIdToSlot.ContainsKey(cellA)); - Assert.True(asm.CellIdToSlot.ContainsKey(cellB)); + Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice + Assert.Contains(cellA, asm.CellIdToSlot.Keys); + Assert.Contains(cellB, asm.CellIdToSlot.Keys); Assert.NotEqual(0, asm.CellIdToSlot[cellA]); Assert.NotEqual(0, asm.CellIdToSlot[cellB]); Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]); - - // Per-cell plane counts recorded (a convex square reduces to 4 planes). Assert.Equal(4, asm.PerCellPlaneCounts[cellA]); Assert.Equal(4, asm.PerCellPlaneCounts[cellB]); - // OutsideView: visible, convex → planes, a non-zero outdoor slot, terrain - // gated via planes. Assert.True(asm.OutdoorVisible); Assert.NotEqual(0, asm.OutdoorSlot); + Assert.Single(asm.OutsideViewSlices); + Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot); Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); Assert.Equal(4, asm.OutsidePlaneCount); Assert.Equal(0, asm.ScissorFallbacks); - - // The outdoor slot differs from both cell slots and from slot 0. - Assert.NotEqual(asm.CellIdToSlot[cellA], asm.OutdoorSlot); - Assert.NotEqual(asm.CellIdToSlot[cellB], asm.OutdoorSlot); } [Fact] public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended() { - // cellA is a real convex region; cellB's CellView is EMPTY → ClipPlaneSet - // .IsNothingVisible → it must NOT be mapped and NOT consume a slot. const uint cellA = 0xA9B40100; const uint cellB = 0xA9B40101; var pv = new PortalVisibilityFrame(); pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); - pv.CellViews[cellB] = new CellView(); // empty ⇒ nothing visible + pv.CellViews[cellB] = new CellView(); pv.OrderedVisibleCells.Add(cellA); pv.OrderedVisibleCells.Add(cellB); - // OutsideView empty ⇒ terrain Skip (the bleed fix), outdoor not visible. - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - // slot 0 + cellA only = 2 slots. cellB consumed none. Assert.Equal(2, asm.Frame.SlotCount); - Assert.True(asm.CellIdToSlot.ContainsKey(cellA)); - Assert.False(asm.CellIdToSlot.ContainsKey(cellB)); // culled — not drawable - - // Empty OutsideView ⇒ outdoor culled + terrain skipped (the bleed fix). + Assert.Contains(cellA, asm.CellIdToSlot.Keys); + Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys); Assert.False(asm.OutdoorVisible); + Assert.Empty(asm.OutsideViewSlices); Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode); Assert.Equal(0, asm.OutsidePlaneCount); } [Fact] - public void OutsideViewScissorFallback_MapsTerrainToScissor_AndCountsFallback() + public void OutsideViewMultiPolygon_PreservesRetailSlices() { - // A MULTI-polygon OutsideView is a union (non-convex) ⇒ ClipPlaneSet falls - // back to the union AABB scissor: mesh outdoor slot 0 (no-clip over-include), - // terrain → Scissor, one fallback counted. const uint cellA = 0xA9B40100; var pv = new PortalVisibilityFrame(); pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); pv.OrderedVisibleCells.Add(cellA); pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f)); - pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); // 2 polys ⇒ scissor fallback + pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.True(asm.OutdoorVisible); - Assert.Equal(0, asm.OutdoorSlot); // no-clip over-include - Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode); - Assert.Equal(0, asm.OutsidePlaneCount); // scissor ⇒ 0 planes - Assert.Equal(1, asm.ScissorFallbacks); - - // The terrain scissor AABB is a valid (min <= max) NDC box spanning both - // OutsideView squares: minX <= -0.6, maxX >= 0.6. - Assert.True(asm.TerrainScissorNdcAabb.X <= asm.TerrainScissorNdcAabb.Z); - Assert.True(asm.TerrainScissorNdcAabb.Y <= asm.TerrainScissorNdcAabb.W); - Assert.True(asm.TerrainScissorNdcAabb.X <= -0.59f); - Assert.True(asm.TerrainScissorNdcAabb.Z >= 0.59f); + Assert.NotEqual(0, asm.OutdoorSlot); + Assert.Equal(2, asm.OutsideViewSlices.Length); + Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot); + Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + Assert.Equal(4, asm.OutsidePlaneCount); + Assert.Equal(0, asm.ScissorFallbacks); + Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices + Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb); } [Fact] - public void CellScissorFallback_MapsCellToSlotZero_AndCountsFallback() + public void CellMultiPolygonView_PreservesRetailViewSlices() { - // A cell with a MULTI-polygon region → scissor fallback → mapped to slot 0 - // (no-clip over-include), recorded with 0 planes, one fallback counted. The - // OutsideView is a single convex region (planes) so only the CELL counts. const uint cellA = 0xA9B40100; var pv = new PortalVisibilityFrame(); - pv.CellViews[cellA] = ViewOf(Square(-0.4f, 0f, 0.1f), Square(0.4f, 0f, 0.1f)); + pv.CellViews[cellA] = ViewOf( + Square(-0.4f, 0f, 0.1f), + Square(0.4f, 0f, 0.1f)); pv.OrderedVisibleCells.Add(cellA); pv.OutsideView.Add(Square(0f, 0f, 0.3f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - // cellA → slot 0 (no-clip). slot 0 + the OutsideView's planes slot = 2. - Assert.Equal(0, asm.CellIdToSlot[cellA]); - Assert.Equal(0, asm.PerCellPlaneCounts[cellA]); - Assert.Equal(2, asm.Frame.SlotCount); // slot0 + OutsideView - Assert.Equal(1, asm.ScissorFallbacks); // the cell fallback + Assert.True(asm.CellIdToSlot[cellA] > 0); + Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length); + Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length); + Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]); + Assert.Equal(4, asm.PerCellPlaneCounts[cellA]); + Assert.Single(asm.OutsideViewSlices); + Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice + Assert.Equal(0, asm.ScissorFallbacks); Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); } - // ----------------------------------------------------------------------- - // Phase W Stage 4: HasOutsideView + OutsideViewNdcAabb - // ----------------------------------------------------------------------- - [Fact] public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds() { - // A single convex quad in OutsideView reduces to Planes. HasOutsideView must - // be true and OutsideViewNdcAabb must match the polygon's own Min/Max values. var pv = new PortalVisibilityFrame(); - var poly = Square(-0.3f, 0.2f, 0.25f); - pv.OutsideView.Add(poly); - // No interior cells needed for this assertion. + pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.True(asm.HasOutsideView); Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + Assert.Single(asm.OutsideViewSlices); - // The OutsideViewNdcAabb must equal the CellView's accumulated Min/Max. - var expected = new System.Numerics.Vector4( + var expected = new Vector4( pv.OutsideView.MinX, pv.OutsideView.MinY, pv.OutsideView.MaxX, pv.OutsideView.MaxY); Assert.Equal(expected, asm.OutsideViewNdcAabb); } [Fact] - public void Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid() + public void Assemble_OutsideViewMultiPolygon_PreservesSlicesAndUnionAabb() { - // Two polygons in OutsideView → union (non-convex) → ClipPlaneSet forces - // scissor fallback. HasOutsideView must still be true, TerrainMode must be - // Scissor, and OutsideViewNdcAabb must equal the union bounds (same values - // as TerrainScissorNdcAabb in this mode). var pv = new PortalVisibilityFrame(); pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f)); pv.OutsideView.Add(Square(0.6f, 0f, 0.15f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.True(asm.HasOutsideView); - Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode); + Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + Assert.Equal(2, asm.OutsideViewSlices.Length); - // Union bounds from the CellView (spans both squares). - var expectedAabb = new System.Numerics.Vector4( + var expected = new Vector4( pv.OutsideView.MinX, pv.OutsideView.MinY, pv.OutsideView.MaxX, pv.OutsideView.MaxY); - Assert.Equal(expectedAabb, asm.OutsideViewNdcAabb); - - // In Scissor mode OutsideViewNdcAabb and TerrainScissorNdcAabb are the same - // value (both are the union CellView bounds). - Assert.Equal(asm.TerrainScissorNdcAabb, asm.OutsideViewNdcAabb); + Assert.Equal(expected, asm.OutsideViewNdcAabb); + Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb); } [Fact] public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero() { - // An empty OutsideView means no exit portal was in view → TerrainMode.Skip, - // HasOutsideView false, OutsideViewNdcAabb degenerate zero. const uint cellA = 0xA9B40100; var pv = new PortalVisibilityFrame(); pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); pv.OrderedVisibleCells.Add(cellA); - // OutsideView left empty (no exit portal). - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.False(asm.HasOutsideView); Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode); - Assert.Equal(System.Numerics.Vector4.Zero, asm.OutsideViewNdcAabb); + Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb); } [Fact] public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies() { - // First assembly packs 2 cells + outdoor (4 slots). Re-assembling the SAME - // frame from a smaller pvFrame must Reset back to slot 0 — no leak. var frame = ClipFrame.NoClip(); var pv1 = new PortalVisibilityFrame(); @@ -249,17 +195,15 @@ public class ClipFrameAssemblerTests var asm1 = ClipFrameAssembler.Assemble(frame, pv1); Assert.Equal(4, asm1.Frame.SlotCount); - // Second assembly: a single cell, no OutsideView. var pv2 = new PortalVisibilityFrame(); pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f)); pv2.OrderedVisibleCells.Add(0xA9B40200); var asm2 = ClipFrameAssembler.Assemble(frame, pv2); - // slot 0 + 1 cell = 2 — the prior 4-slot state did not leak. Assert.Equal(2, asm2.Frame.SlotCount); - Assert.True(asm2.CellIdToSlot.ContainsKey(0xA9B40200)); - Assert.False(asm2.CellIdToSlot.ContainsKey(0xA9B40100)); // gone after Reset - Assert.False(asm2.OutdoorVisible); // no OutsideView this time + Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys); + Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys); + Assert.False(asm2.OutdoorVisible); Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode); } } diff --git a/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs index 1c2a82ab..52bca483 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs @@ -252,4 +252,5 @@ public class ClipPlaneSetTests // need a real AABB; a zero-area line has none). Assert.True(cps.IsNothingVisible); } + } diff --git a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs index 507f4d04..213fe451 100644 --- a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs @@ -10,6 +10,8 @@ public class InteriorEntityPartitionTests { private const uint CellA = 0xA9B40170; private const uint CellB = 0xA9B40171; + private const uint HiddenCell = 0xA9B40199; + private const uint OutdoorCell = 0xA9B40020; private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new() { @@ -28,39 +30,44 @@ public class InteriorEntityPartitionTests (IReadOnlyDictionary?)null) }; [Fact] - public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets() + public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback() { - var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic - var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell - var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static - var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static - var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery + var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null); + var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); + var staticA = Ent(3, serverGuid: 0, parentCell: CellA); + var staticB = Ent(4, serverGuid: 0, parentCell: CellB); + var scenery = Ent(5, serverGuid: 0, parentCell: null); + var liveOutdoor = Ent(6, serverGuid: 0x80005678, parentCell: OutdoorCell); var visible = new HashSet { CellA, CellB }; var result = InteriorEntityPartition.Partition( - visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery)); + visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor)); - Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0) - Assert.Contains(livePlayer, result.LiveDynamic); - Assert.Contains(liveNpcInCell, result.LiveDynamic); + Assert.Single(result.LiveDynamic); + Assert.Contains(unresolvedLive, result.LiveDynamic); - Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic) + Assert.Equal(2, result.ByCell[CellA].Count); + Assert.Contains(liveNpcInCell, result.ByCell[CellA]); Assert.Contains(staticA, result.ByCell[CellA]); Assert.Single(result.ByCell[CellB]); Assert.Contains(staticB, result.ByCell[CellB]); - Assert.Single(result.Outdoor); + Assert.Equal(2, result.Outdoor.Count); Assert.Contains(scenery, result.Outdoor); + Assert.Contains(liveOutdoor, result.Outdoor); } [Fact] - public void Static_InNonVisibleCell_IsDropped() + public void IndoorEntity_InNonVisibleCell_IsDropped() { - var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set + var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell); + var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell); var visible = new HashSet { CellA }; - var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden)); - Assert.False(result.ByCell.ContainsKey(0xA9B40199)); + var result = InteriorEntityPartition.Partition( + visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden)); + + Assert.False(result.ByCell.ContainsKey(HiddenCell)); Assert.Empty(result.Outdoor); Assert.Empty(result.LiveDynamic); } diff --git a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs index c5e1e2bb..56b29c27 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs @@ -169,4 +169,175 @@ public class PortalProjectionTests Assert.True(onScreen.Length >= 3, "the cell behind a doorway you're standing in must stay visible (the void bug)"); } + + // --------------------------------------------------------------------------- + // Faithful homogeneous (w-space) portal clip — port of retail PView::GetClip + + // PrimD3DRender::xformStart + ACRender::polyClipFinish (decomp 432344 / 424310 / + // 702749). The early divide + fixed side-plane clamp (ProjectToNdc) collapsed + // grazing/near portals to zero-area edge slivers (-> the flap) and near doorways + // to empty (-> the void/fallback). The faithful pipeline keeps homogeneous clip + // coords (ProjectToClip — eye-plane clip only, no divide) and runs Sutherland- + // Hodgman against the view region with w-aware edge tests, dividing the survivors + // only after they are bounded to the region (ClipToRegion). 2026-06-06. + // --------------------------------------------------------------------------- + + private static Vector2[] FullScreenCcw() => new[] + { + new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), + }; + + [Fact] + public void ProjectToClip_QuadInFront_KeepsVertsWithPositiveW() + { + var poly = new[] + { + new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + Assert.True(clip.Length >= 3); + foreach (var v in clip) + Assert.True(v.W > 0f, $"an in-front portal vertex must keep w>0 (homogeneous), got w={v.W}"); + } + + [Fact] + public void ProjectToClip_QuadFullyBehind_ReturnsEmpty() + { + var poly = new[] + { + new Vector3(-1, -1, 5), new Vector3(1, -1, 5), new Vector3(1, 1, 5), new Vector3(-1, 1, 5), + }; + Assert.True(PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()).Length < 3); + } + + [Fact] + public void ClipToRegion_OnScreenQuad_ReturnsBoundedNdc() + { + // A small 2x2 quad at z=-5 is fully on-screen; clipping against the full screen + // returns it bounded to [-1,1]. + var poly = new[] + { + new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + [Fact] + public void ClipToRegion_FullyOffScreen_ReturnsEmpty_NotSliver() + { + // The flap's root: a portal entirely off one screen edge. The old early-divide + + // side-plane clamp collapsed it to a zero-area sliver pinned to x=1.0 (proj=3) that + // propagated a degenerate region one hop and then died; the faithful clip returns + // EMPTY so the flood stops cleanly. Quad at z=-5, x in [3,5] -> ndc x ~[1.1,1.8], off right. + var poly = new[] + { + new Vector3(3, -1, -5), new Vector3(5, -1, -5), new Vector3(5, 1, -5), new Vector3(3, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length < 3, $"fully off-screen portal must clip to empty, got {ndc.Length} verts (sliver)"); + } + + [Fact] + public void ClipToRegion_PartlyOffScreen_ClipsToBoundedNonEmpty() + { + // A quad straddling the right edge -> clipped to the on-screen part, bounded, non-empty. + var poly = new[] + { + new Vector3(0, -1, -5), new Vector3(4, -1, -5), new Vector3(4, 1, -5), new Vector3(0, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3, "a partly-on-screen portal must produce a non-empty clipped region"); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + [Fact] + public void ClipToRegion_DoorwayEyeLooksThrough_CoversScreen_WithoutFallback() + { + // The void frame: chase eye 0.28 m from a 2x2 m doorway, looking through it. The doorway + // subtends the whole screen. The faithful clip keeps the homogeneous verts (no early-divide + // blow-up) and clips to the screen quad -> covers the screen (non-empty). This is what makes + // the EyeInsidePortalOpening *fallback* unnecessary for an in-front doorway. + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1.0f, 5000f); + var viewProj = view * proj; + var doorway = new[] + { + new Vector3(-1f, -1f, -0.28f), new Vector3(1f, -1f, -0.28f), + new Vector3(1f, 1f, -0.28f), new Vector3(-1f, 1f, -0.28f), + }; + var clip = PortalProjection.ProjectToClip(doorway, Matrix4x4.Identity, viewProj); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3, "a doorway the eye looks through must cover the screen, not collapse to the void"); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + [Fact] + public void ClipToRegion_StraddlingEye_OnScreenBounded_NoBlowup() + { + // A portal spanning from behind (z=+2) to in front (z=-5). The faithful clip keeps the + // in-front part and bounds it to the screen — no perspective-inversion blow-up, non-empty. + var poly = new[] + { + new Vector3(-1, -1, 2), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, 2), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + private static float AbsArea(Vector2[] p) + { + if (p == null || p.Length < 3) return 0f; + float a2 = 0f; + for (int i = 0; i < p.Length; i++) { var u = p[i]; var w = p[(i + 1) % p.Length]; a2 += u.X * w.Y - w.X * u.Y; } + return MathF.Abs(a2) * 0.5f; + } + + [Fact] + public void ClipToRegion_SubjectFullyInsideRegion_ReturnsSubjectNotRegion() + { + // Regression for Build_AppliesReciprocalOtherPortalClip: a NARROW subject fully inside a WIDE + // region must return the narrow (subject ∩ region = subject), NOT the wide region. The builder's + // reciprocal clip is exactly this shape (reciprocal opening ∩ near-side region). + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); + var vp = view * proj; + var narrow = new[] { new Vector3(-0.3f, -0.9f, -3f), new Vector3(0.3f, -0.9f, -3f), new Vector3(0.3f, 0.9f, -3f), new Vector3(-0.3f, 0.9f, -3f) }; + var wide = new[] { new Vector3(-0.9f, -0.9f, -3f), new Vector3(0.9f, -0.9f, -3f), new Vector3(0.9f, 0.9f, -3f), new Vector3(-0.9f, 0.9f, -3f) }; + + var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp); + // Build the region exactly as the builder does (clip wide against the full screen → CCW region). + var wideRegion = PortalProjection.ClipToRegion(PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp), FullScreenCcw()); + + var clipped = PortalProjection.ClipToRegion(narrowClip, wideRegion); + float narrowArea = AbsArea(PortalProjection.ClipToRegion(narrowClip, FullScreenCcw())); + float wideArea = AbsArea(wideRegion); + float clippedArea = AbsArea(clipped); + Assert.True(clippedArea <= narrowArea + 1e-3f, + $"subject∩region must be the narrow subject (area {narrowArea}), not the wide region (area {wideArea}); got {clippedArea}"); + } + + [Fact] + public void ClipToRegion_AgainstSubRegion_TightensToIntersection() + { + // The region clip is the propagation step: clipping a wide on-screen portal against a + // narrower view region must yield the intersection (the narrow region), not the wide portal. + var wide = new[] + { + new Vector3(-2, -2, -5), new Vector3(2, -2, -5), new Vector3(2, 2, -5), new Vector3(-2, 2, -5), + }; + var clip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, ViewProj()); + var narrow = new[] + { + new Vector2(-0.3f, -0.3f), new Vector2(0.3f, -0.3f), new Vector2(0.3f, 0.3f), new Vector2(-0.3f, 0.3f), + }; + var ndc = PortalProjection.ClipToRegion(clip, narrow); + Assert.True(ndc.Length >= 3); + foreach (var v in ndc) { Assert.InRange(v.X, -0.301f, 0.301f); Assert.InRange(v.Y, -0.301f, 0.301f); } + } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 18aae05e..746a6f44 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -98,6 +98,61 @@ public class PortalVisibilityBuilderTests "a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)"); } + [Fact] + public void Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour() + { + // Live cellar capture (2026-06-06): 0174->0175 was traversable, but the portal projected to + // zero vertices while the chase camera was about 1.4 m from the opening plane. The flood must + // still reach the stair connector; otherwise the main-floor shell/floor disappears. + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); + cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // behind eye: ProjectToNdc collapses + var stairs = Cell(0x0002); + var all = new Dictionary { [0x0001] = cam, [0x0002] = stairs }; + var vp = ViewProj(); + + Assert.True(PortalProjection.ProjectToNdc(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3); + + var frame = PortalVisibilityBuilder.Build( + cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp); + + Assert.Contains(0x0002u, frame.OrderedVisibleCells); + } + + [Fact] + public void Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit() + { + // Retail PView tracks update_count vs view_count. If B is processed through a LEFT slice, then + // a later path reaches B through a RIGHT slice, B must propagate that new RIGHT slice to its + // exit portal. Enqueue-once builders flap here: OutsideView stays empty until the camera moves + // enough to discover B in the other order. + const uint A = 0x0001, B = 0x0002, D = 0x0003; + + var a = Cell(A, + new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0), + new CellPortalInfo((ushort)D, PolygonId: 1, Flags: 0, OtherPortalId: 0)); + a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // nearer LEFT path to B + a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // farther RIGHT path to D + + var b = Cell(B, + new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0), + new CellPortalInfo(0xFFFF, PolygonId: 1, Flags: 0, OtherPortalId: 0), + new CellPortalInfo((ushort)D, PolygonId: 2, Flags: 0, OtherPortalId: 0)); + b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // reciprocal LEFT back to A + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT exit, invisible from LEFT slice + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // reciprocal RIGHT back to D + + var d = Cell(D, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 2)); + d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // later RIGHT path into B + + var all = new Dictionary { [A] = a, [B] = b, [D] = d }; + + var frame = Build(a, all); + + Assert.Contains(B, frame.OrderedVisibleCells); + Assert.Contains(D, frame.OrderedVisibleCells); + Assert.False(frame.OutsideView.IsEmpty); + } + [Fact] public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() { @@ -454,6 +509,117 @@ public class PortalVisibilityBuilderTests "No exit portal in any reachable cell must leave OutsideView empty"); } + [Fact] + public void BuildFromExterior_SeedsInteriorCellThroughOutsidePortal() + { + var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); + room.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 3f, + InsideSide = 1, + }); + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { room }, + Vector3.Zero, + id => id == room.CellId ? room : null, + ViewProj()); + + Assert.Contains(room.CellId, frame.OrderedVisibleCells); + Assert.True(frame.CellViews.TryGetValue(room.CellId, out var view)); + Assert.False(view!.IsEmpty); + Assert.True(view.MaxX - view.MinX < 1.0f, + "exterior seed should be clipped to the door opening, not full-screen"); + } + + [Fact] + public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide() + { + var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); + room.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = -1f, + InsideSide = 1, + }); + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { room }, + Vector3.Zero, + id => id == room.CellId ? room : null, + ViewProj()); + + Assert.Empty(frame.OrderedVisibleCells); + Assert.Empty(frame.CellViews); + } + + [Fact] + public void BuildFromExterior_TraversesDeeperInteriorPortals() + { + var entry = Cell(0x0001, + new CellPortalInfo(0xFFFF, 0, 0, 0), + new CellPortalInfo(0x0002, 1, 0, 0)); + entry.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -3f)); + entry.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, -5f)); + entry.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 2f, + InsideSide = 1, + }); + + var backRoom = Cell(0x0002); + var all = new Dictionary + { + [entry.CellId] = entry, + [backRoom.CellId] = backRoom, + }; + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { entry }, + Vector3.Zero, + id => all.TryGetValue(id, out var c) ? c : null, + ViewProj()); + + Assert.Equal(new uint[] { 0x0001, 0x0002 }, frame.OrderedVisibleCells.ToArray()); + Assert.True(frame.CellViews.ContainsKey(0x0002)); + } + + [Fact] + public void BuildFromExterior_MaxSeedDistanceSkipsDistantExitPortal() + { + var nearby = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + nearby.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); + nearby.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 3f, + InsideSide = 1, + }); + + var distant = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); + distant.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -200f)); + distant.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 199f, + InsideSide = 1, + }); + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { nearby, distant }, + Vector3.Zero, + id => id == nearby.CellId ? nearby : id == distant.CellId ? distant : null, + ViewProj(), + maxSeedDistance: 48f); + + Assert.Contains(nearby.CellId, frame.OrderedVisibleCells); + Assert.DoesNotContain(distant.CellId, frame.OrderedVisibleCells); + } + [Fact] public void Build_RootCellAlwaysFirstInOrderedVisibleCells() { diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs index 9a258119..a596f2cc 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs @@ -1,20 +1,3 @@ -// Tests for WbDrawDispatcher's Phase U.4 per-instance clip-slot resolution -// (ResolveEntitySlot / ResolveSlotForFrame). Code review of the U.4 commit -// (7993e06) flagged this gate-critical routing as untested: if it breaks, -// every indoor instance is sent to the wrong clip slot (or wrongly culled), -// producing total visual garbage at the portal-visibility gate. The logic is -// a pure function of (ServerGuid, ParentCellId, the clip-routing state), so we -// extract it to internal static helpers and test the branches directly — no GL -// context required. -// -// Branch map (ResolveSlotForFrame, the call-site policy): -// routing inactive (outdoor root) → slot 0, NOT culled (≡ U.3) -// ServerGuid != 0 (live dynamic) → slot 0, NOT culled (unclipped) -// ParentCellId in cellIdToSlot → that cell's slot -// ParentCellId NOT in cellIdToSlot → CULL -// ParentCellId == null, outdoorVisible → outdoorSlot -// ParentCellId == null, !outdoorVisible → CULL - using System.Collections.Generic; using AcDream.App.Rendering.Wb; using Xunit; @@ -23,13 +6,10 @@ namespace AcDream.App.Tests.Rendering.Wb; public sealed class WbDrawDispatcherClipSlotTests { - // Full cell-id space keys (lbMask | OtherCellId). 0xA9B4 is the Holtburg - // landblock prefix used throughout the indoor-walking work; the low word is - // the EnvCell index. ParentCellId on a cell static is the SAME full id — see - // the L.2e bare-low-byte finding (a 0x29 low-byte key would cull everything). private const uint VisibleCellA = 0xA9B4_0164u; private const uint VisibleCellB = 0xA9B4_0165u; private const uint NotVisibleCell = 0xA9B4_0999u; + private const uint OutdoorCell = 0xA9B4_0020u; private const int SlotA = 3; private const int SlotB = 7; @@ -41,30 +21,44 @@ public sealed class WbDrawDispatcherClipSlotTests [VisibleCellB] = SlotB, }; - // ── Raw resolver (ResolveEntitySlot): only reached when routing is active ── - [Fact] - public void RawResolve_LiveEntity_IsUnclippedSlot0_WhenParentCellNull() + public void RawResolve_LiveEntity_WithVisibleIndoorParent_GetsThatCellSlot() { - // ServerGuid != 0 ⇒ unclipped (slot 0) regardless of cell state. - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0x5000_000Au, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(0, slot); - } - - [Fact] - public void RawResolve_LiveEntity_IsUnclippedSlot0_EvenWhenParentCellVisible() - { - // A live entity whose ParentCellId IS a visible cell still goes to slot 0, - // NOT SlotA — the live-dynamic check must precede the cell lookup. int slot = WbDrawDispatcher.ResolveEntitySlot( serverGuid: 0x5000_000Au, parentCellId: VisibleCellA, cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - Assert.Equal(0, slot); - Assert.NotEqual(SlotA, slot); // guards against ordering regression + Assert.Equal(SlotA, slot); + } + + [Fact] + public void RawResolve_LiveEntity_WithHiddenIndoorParent_IsCulled() + { + int slot = WbDrawDispatcher.ResolveEntitySlot( + serverGuid: 0x5000_000Au, parentCellId: NotVisibleCell, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); + } + + [Fact] + public void RawResolve_LiveEntity_WithOutdoorParent_UsesOutsideViewWhenVisible() + { + int slot = WbDrawDispatcher.ResolveEntitySlot( + serverGuid: 0x5000_000Au, parentCellId: OutdoorCell, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.Equal(OutsideViewSlot, slot); + } + + [Fact] + public void RawResolve_LiveEntity_WithParentNull_IsCulledWhenRoutingActive() + { + int slot = WbDrawDispatcher.ResolveEntitySlot( + serverGuid: 0x5000_000Au, parentCellId: null, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); } [Fact] @@ -107,19 +101,9 @@ public sealed class WbDrawDispatcherClipSlotTests Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); } - // ── Call-site policy (ResolveSlotForFrame): adds the clipRoutingActive gate ── - // Cases mirror the raw resolver but return the (slot, culled) pair the loop - // body consumes, and add the routing-inactive (outdoor-root) branch. - [Fact] public void ForFrame_RoutingInactive_EveryEntityIsSlot0AndNotCulled() { - // The bit-identical-to-U.3 property: when the camera is at an outdoor root - // (ClearClipRouting), ResolveEntitySlot is never consulted — every entity - // maps to slot 0 and nothing is clip-culled. Exercised here for BOTH a - // live entity and a cell static that would otherwise cull, with a null - // routing map to prove the resolver is bypassed entirely. - var live = WbDrawDispatcher.ResolveSlotForFrame( clipRoutingActive: false, serverGuid: 0x5000_000Au, parentCellId: null, cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: true); @@ -134,16 +118,27 @@ public sealed class WbDrawDispatcherClipSlotTests } [Fact] - public void ForFrame_RoutingActive_LiveEntity_Slot0NotCulled() + public void ForFrame_RoutingActive_LiveEntityVisible_GetsCellSlotNotCulled() { var r = WbDrawDispatcher.ResolveSlotForFrame( clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: VisibleCellA, cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - Assert.Equal(0u, r.Slot); + Assert.Equal((uint)SlotA, r.Slot); Assert.False(r.Culled); } + [Fact] + public void ForFrame_RoutingActive_LiveEntityParentNull_Culled() + { + var r = WbDrawDispatcher.ResolveSlotForFrame( + clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: null, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.True(r.Culled); + Assert.Equal(0u, r.Slot); + } + [Fact] public void ForFrame_RoutingActive_CellStaticVisible_GetsCellSlotNotCulled() { @@ -163,7 +158,6 @@ public sealed class WbDrawDispatcherClipSlotTests cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); Assert.True(r.Culled); - // When culled the loop body forces slot 0 (the value is never emitted). Assert.Equal(0u, r.Slot); } diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index 9fd9232f..fc6e0708 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -96,40 +96,40 @@ public sealed class RenderingDiagnosticsTests // PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the // camera cell, so a chase camera lagging in a doorway while the player was already // outside took the DrawInside path and degenerated to a grey world + entities showing - // through walls. These pin the player-keyed branch (DrawInside root stays the viewer cell). + // through walls. These pin the player-keyed branch and loaded player-root requirement. [Fact] public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse() { // THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the // chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor. - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: true)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: true)); } [Fact] public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue() { - Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: true)); + Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: true)); } [Fact] - public void ShouldRenderIndoor_PlayerInside_NoViewerCell_ReturnsFalse() + public void ShouldRenderIndoor_PlayerInside_RootNotLoaded_ReturnsFalse() { // Opposite lag (camera pulled outside while the player is inside): no viewer cell to // root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior. - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: false)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: false)); } [Fact] public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse() { - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: false)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: false)); } [Fact] public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse() { // playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render). - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, viewerCellResolved: true)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, renderRootResolved: true)); } } diff --git a/tools/TextureDump/Program.cs b/tools/TextureDump/Program.cs index 86cf43f1..f8ab4db2 100644 --- a/tools/TextureDump/Program.cs +++ b/tools/TextureDump/Program.cs @@ -21,11 +21,19 @@ string outDir = Path.Combine(AppContext.BaseDirectory, "out"); Directory.CreateDirectory(outDir); Console.WriteLine($"outDir = {outDir}"); -// Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen -// to 0x050016A0..0x050016AF to catch any related precip textures. -var idList = new System.Collections.Generic.List(); -for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i); -uint[] ids = idList.ToArray(); +uint[] ids; +if (args.Length > 0) +{ + ids = args.Select(ParseId).ToArray(); +} +else +{ + // Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen + // to 0x050016A0..0x050016AF to catch any related precip textures. + var idList = new System.Collections.Generic.List(); + for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i); + ids = idList.ToArray(); +} (uint id, double densityFraction)? best = null; @@ -35,15 +43,25 @@ foreach (var id in ids) Console.WriteLine($"=== 0x{id:X8} ==="); RenderSurface? rs = null; + uint lookupId = id; + if (dats.TryGet(id, out var surface) && surface is not null) + { + lookupId = (uint)surface.OrigTextureId; + var color = surface.ColorValue is null + ? "null" + : $"0x{surface.ColorValue.Alpha:X2}{surface.ColorValue.Red:X2}{surface.ColorValue.Green:X2}{surface.ColorValue.Blue:X2}"; + Console.WriteLine($" Surface descriptor, type={surface.Type}, color={color}, origTexture=0x{lookupId:X8}"); + } + // SurfaceTexture wrapper (0x05xxxxxx) → first inner RenderSurface (0x06xxxxxx). - if (dats.TryGet(id, out var st) && st is not null && st.Textures.Count > 0) + if (dats.TryGet(lookupId, out var st) && st is not null && st.Textures.Count > 0) { uint rsid = (uint)st.Textures[0]; Console.WriteLine($" SurfaceTexture wrapper, {st.Textures.Count} mip(s), first = 0x{rsid:X8}"); if (dats.TryGet(rsid, out var inner) && inner is not null) rs = inner; } - else if (dats.TryGet(id, out var direct) && direct is not null) + else if (dats.TryGet(lookupId, out var direct) && direct is not null) { rs = direct; } @@ -226,3 +244,11 @@ static uint Adler32(byte[] data) } return (b << 16) | a; } + +static uint ParseId(string text) +{ + text = text.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return Convert.ToUInt32(text[2..], 16); + return Convert.ToUInt32(text, 16); +} From bb64a674fce3d4b7b8634331a1bbf6bef2bd1423 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:07:33 +0200 Subject: [PATCH 027/172] =?UTF-8?q?docs:=20spec=20=E2=80=94=20render=20uni?= =?UTF-8?q?fication=20(outdoor-as-a-cell,=20single=20DrawInside=20path)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstormed design to collapse acdream's two render paths (OutdoorRoot vs RetailPViewInside) into one, matching retail SmartBox::RenderNormalMode -> DrawInside(viewer_cell). Roots the FLAP as the two-branch split toggling on the viewer cell crossing the indoor/outdoor boundary (pinned 2026-06-07 via live render-sig); the 2026-06-05 viewer-cell-stability plan (boom + dead-zone + w-clip) is exhausted. Models the outdoor world as a flood-graph cell node whose shell is the landscape, so one flood + one draw handle indoor and outdoor uniformly. Clean cutover, 4-phase plan (phases 1-2 additive, phase 3 the visual-gated cutover). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nder-unification-outdoor-as-cell-design.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md diff --git a/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md b/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md new file mode 100644 index 00000000..a84cb27d --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md @@ -0,0 +1,274 @@ +# Render Unification — Outdoor-as-a-Cell (single DrawInside path) — Design + +**Date:** 2026-06-07 +**Status:** Design approved (brainstorm), pending spec review → implementation plan +**Branch:** `claude/thirsty-goldberg-51bb9b` +**Supersedes (for the flap):** the 2026-06-05 3-part viewer-cell-stability plan +(boom snap + dead-zone + w-clip) — exhausted; see "Why prior fixes failed". + +--- + +## 1. Context & problem + +The indoor render **FLAP**: textures "battle"/oscillate at every transition +(outdoor↔indoor, room↔room, cellar). Walls flash transparent, the world +background covers windows, the doorway appears to "teleport". This has resisted +weeks of incremental fixes. + +**Root cause (pinned 2026-06-07 with live `ACDREAM_PROBE_FLAP` render-sig at the +Holtburg/Arcanum cottage):** the renderer chooses one of **two structurally +different branches** per frame, keyed on whether the *viewer (camera-eye) cell* is +indoor or outdoor ([GameWindow.cs:7342-7349](../../../src/AcDream.App/Rendering/GameWindow.cs)): + +``` +clipRoot = playerIndoorGate && viewerRoot != null ? viewerRoot : null; +renderBranch = clipRoot == null ? "OutdoorRoot" : "RetailPViewInside"; +``` + +The two branches draw the same scene differently: + +| Viewer cell | Branch | Terrain | Interior cells | depth-clear | +|---|---|---|---|---| +| outdoor (e.g. `0xA9B40031`) | `OutdoorRoot` | full-screen draw | **4** via 2D "look-in" | no | +| indoor (`0170`/`0171`) | `RetailPViewInside` | skipped (door-clipped only) | **6** via portal flood | yes | + +The 3rd-person eye sits 2.6–3.9 m behind the player and crosses the indoor/outdoor +boundary by **metres** as the player stands at a doorway. Each crossing toggles the +branch → terrain pops, the interior cell set jumps 4↔6, depth-clear toggles → the +flap. When the eye stays indoor (`0170`↔`0171`) **both solves draw the same 6 cells +— no flap** (verified: adjacent frames, eye ~still). So the flap is *specifically* +the indoor/outdoor branch switch — the "two-pipe split" CLAUDE.md's 2026-05-30 +banner marked abandoned, still alive as this gate. + +## 2. Why prior fixes failed (do not retry) + +- **Viewer-cell dead-zone (±0.2 mm in `PointInsideCellBsp`)** — the eye crosses by + metres; a sub-mm dead-zone is irrelevant. Tried 2026-06-07, had **zero** visible + effect and **regressed** the cellar roof (it shifted the flood *root* via the cell + pick). Reverted. The faithful Part-1 (boom snap, `d2212cf`) and Part-3 (w-space + portal clip, `ProjectToClip`/`ClipToRegion`) are *already shipped*. **The 3-part + viewer-cell-stability plan is exhausted and the flap remains.** +- **Gating the branch on the PLAYER cell** — a documented dead-end + ([GameWindow.cs:7207-7211](../../../src/AcDream.App/Rendering/GameWindow.cs)): + forcing an indoor draw while the camera is outside "drops the outdoor pass and + leaves clear color around a floating doorway slice." When the eye is genuinely + outside, the outdoor view *is* correct — so a stable branch can't be a pure + function of the player cell either. +- **A render-side debounce/grace on the branch** — forbidden (no-workarounds rule; + 2026-06-05 plan §5). + +The lesson: the flap is **architectural**, not a stability tweak. Two +structurally-different render paths cannot be made seamless at the boundary by +adjusting *when* you switch between them. They must become **one** path. + +## 3. The retail model (the oracle) + +Retail has **one** render path. `SmartBox::RenderNormalMode` +(`0x00453aa0`, pc:92635) calls, in the normal case, +`RenderDeviceD3D::DrawInside(viewer_cell)` (`0x0059f0d0`) → +`PView::DrawInside(viewer_cell)` (`0x005a5860`, pc:433793) → `PView::DrawCells` +(`0x005a4840`). It does **not** branch on inside/outside. + +`PView::DrawInside(cell)` (pc:433793): +1. `CEnvCell::curr_view_push(cell)` +2. `PView::add_views(cell->num_stabs, cell->stab_list)` — the cell's visible + objects. **For an outdoor cell the stab list includes the landscape.** +3. `ConstructView(cell, 0xffff)` (`0x005a59a0`) — recursive portal-clip flood. Uses + the ±`0.000199999995f` plane side-test (POSITIVE / NEGATIVE / IN_PLANE, + pc:433834) — *this* is where that 0.2 mm constant belongs, not in cell membership. + An exit/portal leads to `CEnvCell::GetVisible(other_cell_id)` and recurses. +4. `DrawCells(view)` — draw every visible cell. + +**The outdoor world is a cell** (a landcell / `CObjCell`) with portals to buildings +and a stab list that carries the landscape. There is no outdoor "mode" — outdoors +is just the cell you're standing in, drawn by the same `DrawInside`. + +## 4. Goal & non-goals + +**Goal:** collapse acdream's two render paths into one, matching retail: a single +flood rooted at the viewer cell (indoor *or* outdoor) and a single draw of every +visible cell. The flap dies **by construction** — there is no branch to flip, and +crossing a doorway is one continuous flood whose output varies continuously. + +**Approach chosen (brainstorm 2026-06-07):** "A — make outdoor a cell" (the true +retail model), with a **clean cutover** (no toggle; git revert is the safety net). + +**Non-goals (out of scope for this work):** +- Camera collision / viewer-cell resolution behaviour (kept as-is — it is correct; + the flap is not a camera bug). +- `#78` outdoor terrain gating over indoor floor holes (tracked separately). +- L-spotlight point-light artifact (separate). +- Per-landcell outdoor granularity (retail has 64 landcells/landblock for its own + landscape culling; we model the outdoor world as **one** flood node whose shell is + the terrain — acdream's terrain renderer already does its own frustum culling). + +## 5. Design overview + +One operation per frame: **flood from the viewer's cell; draw every visible cell.** +The only new concept is an **outdoor cell node**: a synthetic cell whose "shell" is +the landscape and whose "doorways" are nearby building entrances. With it, the +outdoor case and the indoor case are the *same* graph problem. + +``` +resolve viewer cell ─► Build(flood) from viewer cell ─► for each visible cell in +draw order: (outdoor node → terrain+sky+scenery clipped to its region │ interior +cell → shell+objects clipped to its region) ─► entities membership-gated +``` + +## 6. Components + +### 6.1 Outdoor cell node — `CellVisibility` (+ a small new type) +A synthetic node representing the outdoor world near the player. **One node per +frame**, keyed by the viewer's current outdoor landcell id (the id the camera already +produces, e.g. `0xA9B40031`). All building exit portals collapse to this single node +(the landscape is global, so we do not model 64 landcells/landblock — the node's shell +is the whole visible landscape, drawn by the terrain renderer's own frustum cull). +- `SeenOutside = true`, world transform = identity. +- **Portals = the entrances of nearby buildings** — the reverse of each building's + exit portal (`OtherCellId == 0xFFFF`) polygon, with its clip plane reversed so the + flood can traverse outdoor→building. Built per-frame from the **same nearby-building + enumeration the current exterior look-in already does** + ([GameWindow.cs:~7538-7565](../../../src/AcDream.App/Rendering/GameWindow.cs)). +- Marked so the draw path knows "this cell's shell is the terrain, not EnvCell + geometry." +- `CellVisibility.TryGetCell` / the viewer-cell resolution at + [GameWindow.cs:7201-7204](../../../src/AcDream.App/Rendering/GameWindow.cs) returns + this node when the camera's viewer-cell id is an outdoor id, so `viewerRoot` is + **non-null outdoors** and `ComputeVisibilityFromRoot` runs the flood. + +**Interface:** `what` — represents the outdoor world as a flood graph node; +`how to use` — `CellVisibility` builds/refreshes it per frame and returns it from the +viewer-cell lookup when outdoors; `depends on` — the nearby-building portal +enumeration and the loaded landblock set. + +### 6.2 One flood — `PortalVisibilityBuilder.Build` +- `Build` roots at the viewer cell — interior `EnvCell` **or** the outdoor node + (today it requires a non-null interior `LoadedCell`, + [PortalVisibilityBuilder.cs:63](../../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)). +- Building exit portals (`OtherCellId == 0xFFFF`, + [PortalVisibilityBuilder.cs:234](../../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) + now **lead to the outdoor node** and enqueue it (clipped to the door opening), + instead of terminating into a 2D `OutsideView`. +- The outdoor↔building cycle is bounded by the existing visited-set / per-cell + reprocess guard (`MaxReprocessPerCell`). +- **`BuildFromExterior` is deleted** — the exterior look-in becomes "the flood, + rooted at the outdoor node." + +**Interface:** `what` — given any root cell + eye, returns the set of visible cells +(interior + outdoor node) with per-cell screen-space clip regions; `how to use` — +called once per frame from the viewer cell; `depends on` — the cell graph (now +including the outdoor node) and `PortalProjection`. + +### 6.3 One draw path — `RetailPViewRenderer` + `GameWindow` +- Delete the `OutdoorRoot`/`RetailPViewInside` branch + ([GameWindow.cs:7342-7349](../../../src/AcDream.App/Rendering/GameWindow.cs)) and + its two blocks. +- Walk the flood's visible cells in draw order; for each: + - **Outdoor node** → draw terrain + sky + outdoor scenery, clipped to that node's + view region. + - **Interior cell** → draw shell + objects, clipped to its region (today's + `IndoorDrawPlan.ShellPass`/`ObjectPass`). +- Entities membership-gated as today (`InteriorEntityPartition`). +- Delete the separate `OutsideView` mechanism + + `DrawLandscapeThroughOutsideView`/`DrawRetailPViewLandscapeSlice` + ([GameWindow.cs:~9239](../../../src/AcDream.App/Rendering/GameWindow.cs)) and + `RetailPViewRenderer.DrawPortal` — terrain-through-door is now "the outdoor node + drawn clipped to its doorway region," produced by the unified flood. + +### 6.4 Terrain-clipped-to-region — `TerrainModernRenderer` (reused) +No new terrain machinery. Terrain already clips to the `OutsideView` doorway planes +in-shader via `gl_ClipDistance` (binding=2 clip UBO, +[TerrainModernRenderer.cs:206](../../../src/AcDream.App/Rendering/TerrainModernRenderer.cs)). +We drive that clip from the **outdoor node's view region** instead: full-screen (no +clip — byte-identical to today's open-world draw) when the outdoor node is the root, +the doorway region when it is reached through a portal. + +## 7. Per-frame data flow + +1. Resolve the viewer cell: interior `EnvCell` if the eye is indoors, else the + outdoor node (`CellVisibility`). +2. `Build` the visibility flood from the viewer cell (one call). +3. Draw every visible cell in order: outdoor node → terrain/sky/scenery clipped to + its region; interior cell → shell/objects clipped to its region. +4. Draw entities, membership-gated to visible cells. +5. Sky draws when the outdoor node is in the visible set, clipped to its region. + +No branch anywhere in steps 2–5. + +## 8. What gets deleted (clean cutover) + +- The two-branch gate + blocks ([GameWindow.cs:7342-7349](../../../src/AcDream.App/Rendering/GameWindow.cs) and the outdoor/indoor draw blocks). +- `PortalVisibilityBuilder.BuildFromExterior`. +- `RetailPViewRenderer.DrawPortal` (the exterior look-in). +- The `OutsideView` 2D-region path + `DrawLandscapeThroughOutsideView` / + `DrawRetailPViewLandscapeSlice`. + +Net result: less code, one path. + +## 9. Phasing (implementation order) + +1. **Outdoor cell node** — construct it + wire building-entrance portals; resolve + `viewerRoot` to it outdoors. *Unit-tested* (node has the right portals; viewer + resolution returns it for outdoor ids). Additive — not yet consumed by the draw. +2. **Outdoor-root flood capability** — `Build` *can* root at the outdoor node and + flood into buildings through their entrances; cycle-safe. This is a **new, + additive** path: the existing indoor `Build` and its exit-portal→`OutsideView` + behaviour are left untouched so the live draw stays correct. *Unit-tested* (flood + from the outdoor node reaches buildings; termination on the outdoor↔building cycle). +3. **Cutover (the one risky, visual-gated step)** — switch the draw to the single + unified path; repoint building exit portals from `OutsideView` to the outdoor node + (so indoor→outdoor floods into the node through the door); delete the + `OutdoorRoot`/`RetailPViewInside` branch, `BuildFromExterior`, + `RetailPViewRenderer.DrawPortal`, and the `OutsideView` / + `DrawLandscapeThroughOutsideView` path. **Visual gate (user's eyes)** at the + cottage doorway/cellar/look-in-from-outside. +4. **Cleanup** — remove any remaining dead code; reconcile the `[render-sig]` probe + to the single path. + +Phases 1–2 are **purely additive** — the new node + outdoor-root flood are not yet +consumed by the draw, and the exit-portal→`OutsideView` behaviour is unchanged — so +the build stays green and the game renders exactly as today. The disruptive change +(repointing exit portals + switching the draw + deleting the old paths) is isolated to +phase 3 and is git-revertible as a unit. + +## 10. Testing strategy + +- **Unit (TDD):** outdoor-node portal wiring; viewer-cell resolution to the node; + `Build` rooted at the outdoor node returns the expected cell set; indoor→outdoor + flood through a door; cycle termination. New tests in + `tests/AcDream.App.Tests/Rendering/`. +- **Regression guard:** the **pure-outdoor case** (no building in view) must stay + byte-for-byte today's behaviour — full-screen terrain, no clip — so open-world + rendering cannot regress. Assert the outdoor node's root region is full-screen and + the terrain clip is the no-clip UBO in that case. +- **Visual gate (acceptance):** user walks in/out of the cottage, pans the camera at + the threshold, drops to the cellar and back, looks at the cottage from outside — + no flap, no missing textures, terrain/sky correct, no see-through walls. + +## 11. Risks & mitigations + +- **Occlusion/ordering when terrain + interiors share one flood** — keep the draw + order retail-faithful (far→near, exit-portal masks as today); mitigate by keeping + the pure-outdoor path identical (regression guard above). +- **Cycle blow-up (outdoor→building→outdoor)** — reuse the existing visited-set + + `MaxReprocessPerCell` cap; unit-test termination at the cottage. +- **Performance** — the outdoor node is a single flood node (terrain is its shell, + drawn once with its own frustum cull), not millions of cells, so the flood does not + become combinatorial. Watch outdoor FPS at the visual gate. +- **Clean cutover (no toggle)** — phases 1–2 are additive and green; phase 3 is the + only risky step and is git-revertible as a unit. + +## 12. References / decomp anchors + +- Retail: `SmartBox::RenderNormalMode` `0x00453aa0` (pc:92635); + `RenderDeviceD3D::DrawInside` `0x0059f0d0`; `PView::DrawInside` `0x005a5860` + (pc:433793); `PView::ConstructView` `0x005a59a0` (side-test pc:433834); + `PView::DrawCells` `0x005a4840`; `PView::GetClip` `0x005a4320`. +- acdream: `GameWindow.cs` 7183-7204 (viewer/player root), 7342-7349 (branch), + ~7538-7601 (look-in), ~9239 (landscape-through-OutsideView); + `PortalVisibilityBuilder.cs` 63 (Build), 234 (exit portal), 339 (BuildFromExterior), + 664 (side-test); `PortalProjection.cs` (w-space clip); `TerrainModernRenderer.cs` + 206 (Draw/clip); `CellVisibility.cs` 276 (TryGetCell), 338 (ComputeVisibilityFromRoot). +- Memory: `project_indoor_flap_rootcause`, `reference_render_pipeline_state`, + `feedback_render_one_gate`, `feedback_render_downstream_of_membership`, + `project_camera_visibility_coupling`. From 06666b75a14932a4f51d28987c64cb6dcdf507fe Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:12:58 +0200 Subject: [PATCH 028/172] =?UTF-8?q?docs:=20plan=20=E2=80=94=20render=20uni?= =?UTF-8?q?fication=20(outdoor-as-a-cell),=204-phase=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bite-sized TDD plan for the unification spec. Phase 1 (outdoor node) + Phase 2 (outdoor-root flood) are additive + unit-tested (full code/tests in the plan). Phase 3 is the single visual-gated cutover (wire one path, repoint exit portals, delete the branch/BuildFromExterior/DrawPortal/OutsideView). Phase 4 cleanup. Pure-outdoor regression guard keeps open-world rendering byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-render-unification-outdoor-as-cell.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md diff --git a/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md new file mode 100644 index 00000000..88053100 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md @@ -0,0 +1,315 @@ +# Render Unification (Outdoor-as-a-Cell) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Collapse acdream's two render paths (`OutdoorRoot` vs `RetailPViewInside`) into one — a single portal flood rooted at the viewer cell (indoor *or* a new outdoor cell node) and a single draw of every visible cell — so the indoor/outdoor FLAP is impossible by construction. + +**Architecture:** Model the outdoor world as a synthetic `LoadedCell` flood node whose "shell" is the landscape and whose "doorways" are nearby building entrances. `PortalVisibilityBuilder.Build` roots at the viewer cell; building exit portals lead to the outdoor node; the draw path renders each visible cell uniformly (outdoor node → terrain/sky; interior → shell). Matches retail `SmartBox::RenderNormalMode → DrawInside(viewer_cell)`. + +**Tech Stack:** C# .NET 10, Silk.NET OpenGL, xUnit. Render code in `src/AcDream.App/Rendering/`. Tests in `tests/AcDream.App.Tests/Rendering/`. + +**Spec:** [docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md](../specs/2026-06-07-render-unification-outdoor-as-cell-design.md) + +**Baselines that must hold:** build 0 errors; App.Tests 210 pass; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Run the client per CLAUDE.md "Running the client"; `+Acdream` spawns at the Holtburg/Arcanum cottage. Launch logs are UTF-16. Use `ACDREAM_PROBE_FLAP` only (NOT `ACDREAM_PROBE_SHELL`). + +--- + +## File structure + +| File | Responsibility | Change | +|---|---|---| +| `src/AcDream.App/Rendering/OutdoorCellNode.cs` | Build a synthetic outdoor `LoadedCell` from nearby building exit portals | **Create** | +| `src/AcDream.App/Rendering/CellVisibility.cs` | `LoadedCell`/`CellPortalInfo`/`PortalClipPlane` types; cell registry; `TryGetCell`; resolve the outdoor node | Modify | +| `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | The one flood; root at outdoor node; exit portals → outdoor node | Modify | +| `src/AcDream.App/Rendering/RetailPViewRenderer.cs` | The one draw path; outdoor-node-aware cell draw | Modify | +| `src/AcDream.App/Rendering/GameWindow.cs` | Per-frame: resolve viewer cell, one flood, one draw; delete the branch | Modify | +| `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs` | Outdoor node construction + portal wiring | **Create** | +| `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs` | Build rooted at the outdoor node; cycle termination | **Create** | + +**Deletions (Phase 3):** `PortalVisibilityBuilder.BuildFromExterior`; `RetailPViewRenderer.DrawPortal`; the `OutsideView` mechanism + `GameWindow.DrawRetailPViewLandscapeSlice` / `DrawLandscapeThroughOutsideView`; the two-branch gate at `GameWindow.cs:7342-7349`. + +--- + +## PHASE 1 — The outdoor cell node (additive; not yet consumed by the draw) + +### Task 1: `OutdoorCellNode.Build` — synthesize the outdoor node from nearby building entrances + +**Files:** +- Create: `src/AcDream.App/Rendering/OutdoorCellNode.cs` +- Test: `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs` + +Context: a building cell stores its entrance as a portal with `OtherCellId == 0xFFFF` (exit-to-outdoors) in `Portals[i]`, with the matching `ClipPlanes[i]` (local-space `Normal`,`D`,`InsideSide`) and `PortalPolygons[i]` (local-space verts). The outdoor node is a `LoadedCell` with `WorldTransform = Identity` whose `Portals` point *back into* each building cell, with the entrance polygon transformed to world space and the clip plane reversed (`InsideSide` flipped) so "inside the outdoor node" is the half-space outside the building. + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class OutdoorCellNodeTests +{ + // A building cell at world-translate (10,0,0) with one exit portal (OtherCellId=0xFFFF) + // whose local plane faces +X (InsideSide=0). The outdoor node must expose ONE portal + // back into that building cell, with the entrance polygon moved to world space and the + // inside-side flipped (so the outdoor half-space is "inside" the node). + private static LoadedCell BuildingWithOneExit(uint cellId) + { + var cell = new LoadedCell { CellId = cellId }; + cell.WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f); + cell.InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f); + cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0)); + cell.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(1, 0, 0), D = 0f, InsideSide = 0 }); + cell.PortalPolygons.Add(new[] + { + new Vector3(0, -1, 0), new Vector3(0, 1, 0), new Vector3(0, 1, 2), new Vector3(0, -1, 2) + }); + return cell; + } + + [Fact] + public void Build_FromBuildingExit_AddsReversePortalIntoBuilding() + { + uint outdoorId = 0xA9B40031; + var building = BuildingWithOneExit(0xA9B40170); + var node = OutdoorCellNode.Build(outdoorId, new[] { building }); + + Assert.Equal(outdoorId, node.CellId); + Assert.True(node.SeenOutside); + Assert.Equal(Matrix4x4.Identity, node.WorldTransform); + Assert.Single(node.Portals); + Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId); + // Reversed inside-side: the building's exit was InsideSide=0, the node's is 1. + Assert.Equal(1, node.ClipPlanes[0].InsideSide); + // Entrance polygon moved to world space (building translated +10 X): first vert x≈10. + Assert.Equal(10f, node.PortalPolygons[0][0].X, 3); + } + + [Fact] + public void Build_NoBuildings_ReturnsEmptyPortalNode() + { + var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty()); + Assert.Empty(node.Portals); + Assert.True(node.SeenOutside); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"` +Expected: FAIL — `OutdoorCellNode` does not exist (compile error). + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// src/AcDream.App/Rendering/OutdoorCellNode.cs +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell +/// (spec 2026-06-07-render-unification-outdoor-as-cell). Its "shell" is the landscape +/// (drawn by the terrain renderer); its portals are the reverse of each nearby +/// building's exit portal (OtherCellId==0xFFFF). One node per frame, keyed by the +/// viewer's outdoor landcell id. WorldTransform is identity (portals stored in world +/// space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell) roots at. +/// +public static class OutdoorCellNode +{ + public static LoadedCell Build(uint outdoorCellId, IReadOnlyList nearbyBuildingCells) + { + var node = new LoadedCell + { + CellId = outdoorCellId, + SeenOutside = true, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + foreach (var bcell in nearbyBuildingCells) + { + for (int i = 0; i < bcell.Portals.Count; i++) + { + if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors + if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue; + + // Reverse portal: outdoor node -> this building cell. + node.Portals.Add(new CellPortalInfo( + OtherCellId: (ushort)(bcell.CellId & 0xFFFFu), + PolygonId: bcell.Portals[i].PolygonId, + Flags: bcell.Portals[i].Flags, + OtherPortalId: (ushort)i)); + + // Entrance polygon -> world space (node transform is identity). + var srcPoly = bcell.PortalPolygons[i]; + var worldPoly = new Vector3[srcPoly.Length]; + for (int v = 0; v < srcPoly.Length; v++) + worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform); + node.PortalPolygons.Add(worldPoly); + + // Clip plane -> world space, inside-side flipped (outdoor half-space is "inside"). + var src = bcell.ClipPlanes[i]; + var worldNormal = Vector3.TransformNormal(src.Normal, bcell.WorldTransform); + worldNormal = Vector3.Normalize(worldNormal); + var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform); + node.ClipPlanes.Add(new PortalClipPlane + { + Normal = worldNormal, + D = -Vector3.Dot(worldNormal, pointOnPlane), + InsideSide = src.InsideSide == 0 ? 1 : 0, + }); + } + } + + return node; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"` +Expected: PASS (2 tests). If `LoadedCell.CellId` is not settable from tests, confirm its declaration in `CellVisibility.cs` and adjust (it is a public field used as `cameraCell.CellId` throughout the builder). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/OutdoorCellNode.cs tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs +git commit -m "feat(render): Phase 1 — OutdoorCellNode.Build (outdoor world as a flood node)" +``` + +### Task 2: Resolve `viewerRoot` to the outdoor node when the eye is outdoors + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:7201-7204` (viewerRoot resolution) +- Modify: `src/AcDream.App/Rendering/CellVisibility.cs` (add `GetNearbyBuildingCellsForExterior` if not already exposed; the look-in enumeration at `GameWindow.cs:~7538-7565` already gathers candidate cells — reuse it) + +Note: this step builds the node and stores it on a field but **does not yet feed it to the flood/draw** — the existing branch still runs. Purely additive; the only observable change is that `viewerRoot` is non-null outdoors (verify via `[render-sig]` `viewerRoot=` once wired in Phase 3; for now assert via a focused test or a temporary log). + +- [ ] **Step 1:** Add a `private LoadedCell? _outdoorNode;` field to `GameWindow` and, right after the existing `viewerRoot` block (`GameWindow.cs:7201-7203`), when `viewerRoot is null && viewerCellId != 0u` (outdoor id), build the node from the nearby building cells (reuse the exterior-candidate enumeration already at ~7538-7565, extracted into a helper `GatherNearbyBuildingCells(playerLb)` returning `IReadOnlyList`), assign `_outdoorNode = OutdoorCellNode.Build(viewerCellId, nearby);` and **leave `viewerRoot` unchanged for now** (Phase 3 flips the consumer). Add a one-line `[render-sig]`-adjacent log behind `ProbeFlapEnabled`: `outdoorNode portals=N` to confirm wiring live. + +- [ ] **Step 2:** `dotnet build -c Debug` → 0 errors. `dotnet test` both suites → baselines hold (210 / 1331-4-1). No behavior change yet. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/CellVisibility.cs +git commit -m "feat(render): Phase 1 — build the outdoor node each frame (additive, unconsumed)" +``` + +--- + +## PHASE 2 — Outdoor-root flood capability (additive; old exit-portal behaviour untouched) + +### Task 3: `PortalVisibilityBuilder.Build` floods from the outdoor node into buildings + +**Files:** +- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (the `Build` seed + portal loop at lines 63, 133-318) +- Test: `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs` + +Context: `Build(cameraCell, cameraPos, lookup, viewProj)` seeds the root full-screen and floods interior portals. Rooting at the outdoor node already works structurally (it's a `LoadedCell` with portals into buildings). This task is a **characterization test** proving Build floods outdoor→building, plus any fix needed for the outdoor node's identity-transform portals (its polygons are already world-space, so `ProjectToClip(localPoly, node.WorldTransform=Identity, viewProj)` is correct). + +- [ ] **Step 1: Write the failing test** (real fixture: outdoor node + one building cell reachable through it) + +```csharp +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class UnifiedFloodTests +{ + [Fact] + public void Build_RootedAtOutdoorNode_FloodsIntoBuilding() + { + // Building cell directly in front of the eye, with an exit portal facing the eye. + var building = new LoadedCell { CellId = 0xA9B40170, SeenOutside = true }; + building.WorldTransform = Matrix4x4.Identity; + building.InverseWorldTransform = Matrix4x4.Identity; + building.Portals.Add(new CellPortalInfo(0xFFFF, 0, 0, 0)); + building.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, -1, 0), D = 5f, InsideSide = 0 }); + building.PortalPolygons.Add(new[] + { + new Vector3(-1, 5, 0), new Vector3(1, 5, 0), new Vector3(1, 5, 2), new Vector3(-1, 5, 2) + }); + + var node = OutdoorCellNode.Build(0xA9B40031, new[] { building }); + LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null; + + // Eye in front of the entrance, looking +Y toward it. + var eye = new Vector3(0, -3, 1); + var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f); + + var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj); + + Assert.Contains(0xA9B40031u, frame.OrderedVisibleCells); // the outdoor node itself + Assert.Contains(0xA9B40170u, frame.OrderedVisibleCells); // flooded into the building + } +} +``` + +- [ ] **Step 2: Run to verify it fails or passes.** Run the filter `UnifiedFloodTests`. If it FAILS (building not flooded), inspect why (likely the lookup keys on full id vs low id, or the node's world-space polygon needs identity transform in `Build`'s projection call). Fix minimally in `PortalVisibilityBuilder`. If it PASSES first try, it's a characterization test that locks the behaviour — keep it. + +- [ ] **Step 3: Cycle-termination test** — add a reciprocal exit portal on the building back to the outdoor node and assert `Build` terminates (no hang, bounded `OrderedVisibleCells`). The existing `queued`/`MaxReprocessPerCell` guards should cover it; this test pins it. + +- [ ] **Step 4:** `dotnet test` both suites → baselines hold + the new tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs +git commit -m "feat(render): Phase 2 — Build floods from the outdoor node into buildings (+cycle guard test)" +``` + +--- + +## PHASE 3 — The cutover (the one risky, visual-gated step) + +> Each task here is git-revertible as a unit. After Task 7, **stop for the user's visual gate** before Task 8's deletions. + +### Task 4: Exit portals enqueue the outdoor node + +- [ ] In `PortalVisibilityBuilder.Build`, at the exit-portal branch (`PortalVisibilityBuilder.cs:234`, `portal.OtherCellId == 0xFFFF`), instead of (only) `AddRegion(frame.OutsideView, clippedRegion)`, resolve the outdoor node via the `lookup` (Phase 1 makes it resolvable) and enqueue it with `clippedRegion` as its view, exactly like an interior neighbour (the `AddRegion(nview,…)` + `queued.Add` path at lines 296-316). Keep `OutsideView` populated too **for this task only** (so the old draw still works) — it is removed in Task 7. Run `UnifiedFloodTests` + add a test: indoor root → flood reaches the outdoor node through the exit portal. +- [ ] Commit: `feat(render): Phase 3 — exit portals flood into the outdoor node`. + +### Task 5: Unified draw — render the outdoor node's shell as terrain + +- [ ] In `RetailPViewRenderer` (the visible-cell draw walk, `DrawEnvCellShells`/`IndoorDrawPlan.ShellPass`), special-case the outdoor node: when the visible cell is the outdoor node, draw terrain + sky + outdoor scenery clipped to that cell's view region (reuse the existing terrain clip mechanism — drive `TerrainModernRenderer`'s binding=2 clip UBO from the node's region planes; full-screen region → the existing no-clip UBO) instead of EnvCell shell geometry. Interior cells unchanged. +- [ ] Build green; commit: `feat(render): Phase 3 — draw the outdoor node's shell as terrain (unified draw)`. + +### Task 6: Route the frame through the single path + +- [ ] At `GameWindow.cs:7342-7349`, replace the branch so `viewerRoot` is the outdoor node when outdoors (Task 2 already builds it; assign `viewerRoot = _outdoorNode` when the prior lookup was null and an outdoor node exists). Set `clipRoot = viewerRoot` unconditionally (drop the `playerIndoorGate && viewerRoot != null` gate). The single draw path (`RetailPViewRenderer.DrawInside`) now runs every frame, rooted at the viewer cell. +- [ ] Build green; `dotnet test` baselines. Commit: `feat(render): Phase 3 — single render path rooted at the viewer cell`. + +### Task 7: **VISUAL GATE** — user verifies, then delete the old paths + +- [ ] Build, launch (`ACDREAM_PROBE_FLAP=1`, UTF-16 log). **User test** at the Holtburg cottage: walk in/out, pan at the threshold, cellar down/up, look at the cottage from outside. Acceptance: no flap; no missing wall/roof textures; terrain + sky correct; no see-through walls; pure-outdoor FPS unchanged. Capture `[render-sig]` — `branch` is now always the single path; `viewerCell`/`draw` transition cleanly with no 4↔6 cell-set jump. +- [ ] **Only after the user confirms:** delete `PortalVisibilityBuilder.BuildFromExterior`, `RetailPViewRenderer.DrawPortal`, the `OutsideView` field + `AddRegion(frame.OutsideView,…)`, and `GameWindow.DrawRetailPViewLandscapeSlice`/`DrawLandscapeThroughOutsideView` + the now-dead outdoor-branch block. Build green; `dotnet test` baselines. +- [ ] Commit: `feat(render): Phase 3 — delete two-pipe split (BuildFromExterior/DrawPortal/OutsideView)`. + +--- + +## PHASE 4 — Cleanup + +### Task 8: Reconcile probes + dead code + +- [ ] Update the `[render-sig]` emit (`GameWindow.cs:~9039-9082`) so `branch` reflects the single path and the now-removed `extPortal/extIds/outdoorRoot*` fields are dropped or repurposed. Remove any now-unreachable helpers flagged by the build. Update `docs/research` / memory `project_indoor_flap_rootcause` + `reference_render_pipeline_state` with the shipped outcome. +- [ ] Update the roadmap "shipped" table (`docs/plans/2026-04-11-roadmap.md`) + the milestones doc M1.5 note. Commit: `chore(render): Phase 4 — probe + docs reconcile after unification`. + +--- + +## Self-review + +- **Spec coverage:** §6.1 outdoor node → Task 1/2; §6.2 one flood → Task 3/4; §6.3 one draw + deletions → Task 5/6/7; §6.4 terrain clip reuse → Task 5; §9 phasing → Phases 1-4 (1-2 additive, 3 cutover, 4 cleanup); §10 testing → Tasks 1/3 unit + Task 7 visual gate + the pure-outdoor regression guard (assert in Task 5/6 that an outdoor root with no buildings yields a full-screen no-clip terrain draw). **Gap fixed:** add to Task 6 an explicit assertion/log that the no-building outdoor case routes to the no-clip terrain UBO (regression guard from spec §10). +- **Placeholders:** Phases 1-2 carry real test + impl code. Phase 3-4 are concrete wiring/deletion tasks against named methods (their exact code is finalized against the Phase 1-2 APIs at execution — the cutover is inherently wire-and-delete + visual gate, not new algorithm). No "TBD"/"add error handling". +- **Type consistency:** `LoadedCell` (fields `CellId`, `Portals`, `ClipPlanes`, `PortalPolygons`, `WorldTransform`, `InverseWorldTransform`, `SeenOutside`), `CellPortalInfo(OtherCellId,PolygonId,Flags,OtherPortalId)`, `PortalClipPlane{Normal,D,InsideSide}`, `OutdoorCellNode.Build(uint, IReadOnlyList) → LoadedCell`, `PortalVisibilityBuilder.Build(LoadedCell, Vector3, Func, Matrix4x4)` — consistent across tasks. + +**Note for the executor:** confirm `LoadedCell.CellId` is a settable public field and the exact `PortalVisibilityBuilder.Build` signature against `CellVisibility.cs`/`PortalVisibilityBuilder.cs:63` before Task 1/3 (the plan assumes the signatures observed 2026-06-07). Phase 3 tasks reference real method names to wire/delete; read each call site before editing. From 2a2cc97d288f538c6b0d702883bb9ace2996754c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:16:55 +0200 Subject: [PATCH 029/172] =?UTF-8?q?feat(render):=20Phase=201=20=E2=80=94?= =?UTF-8?q?=20OutdoorCellNode.Build=20(outdoor=20world=20as=20a=20flood=20?= =?UTF-8?q?node)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purely additive: creates the synthetic outdoor cell node that will serve as the flood-graph root for the unified render pipeline. Each nearby building's exit portal (OtherCellId==0xFFFF) is reversed into a portal pointing back into the building, with its polygon transformed to world space and InsideSide flipped so the outdoor half-space is "inside" the node. WorldTransform=Identity (portals in world space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell) roots at (SmartBox::RenderNormalMode, decomp pc:92635). Nothing consumes this yet — consumer wiring is Task 2. 2 new tests, 212 total passing, 0 regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/OutdoorCellNode.cs | 63 +++++++++++++++++++ .../Rendering/OutdoorCellNodeTests.cs | 52 +++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/AcDream.App/Rendering/OutdoorCellNode.cs create mode 100644 tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs new file mode 100644 index 00000000..0830f096 --- /dev/null +++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell +/// (spec docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md). +/// Its "shell" is the landscape (drawn by the terrain renderer); its portals are the +/// reverse of each nearby building's exit portal (OtherCellId==0xFFFF). One node per +/// frame, keyed by the viewer's outdoor landcell id. WorldTransform is identity +/// (portals stored in world space). Mirrors retail's outdoor landcell that +/// DrawInside(viewer_cell) roots at (SmartBox::RenderNormalMode, decomp pc:92635). +/// +public static class OutdoorCellNode +{ + public static LoadedCell Build(uint outdoorCellId, IReadOnlyList nearbyBuildingCells) + { + var node = new LoadedCell + { + CellId = outdoorCellId, + SeenOutside = true, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + foreach (var bcell in nearbyBuildingCells) + { + for (int i = 0; i < bcell.Portals.Count; i++) + { + if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors + if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue; + + // Reverse portal: outdoor node -> this building cell. + node.Portals.Add(new CellPortalInfo( + OtherCellId: (ushort)(bcell.CellId & 0xFFFFu), + PolygonId: bcell.Portals[i].PolygonId, + Flags: bcell.Portals[i].Flags, + OtherPortalId: (ushort)i)); + + // Entrance polygon -> world space (node transform is identity). + var srcPoly = bcell.PortalPolygons[i]; + var worldPoly = new Vector3[srcPoly.Length]; + for (int v = 0; v < srcPoly.Length; v++) + worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform); + node.PortalPolygons.Add(worldPoly); + + // Clip plane -> world space, inside-side flipped (outdoor half-space is "inside"). + var src = bcell.ClipPlanes[i]; + var worldNormal = Vector3.Normalize(Vector3.TransformNormal(src.Normal, bcell.WorldTransform)); + var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform); + node.ClipPlanes.Add(new PortalClipPlane + { + Normal = worldNormal, + D = -Vector3.Dot(worldNormal, pointOnPlane), + InsideSide = src.InsideSide == 0 ? 1 : 0, + }); + } + } + + return node; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs new file mode 100644 index 00000000..1129b295 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class OutdoorCellNodeTests +{ + // A building cell at world-translate (10,0,0) with one exit portal (OtherCellId=0xFFFF) + // whose local plane faces +X (InsideSide=0). The outdoor node must expose ONE portal + // back into that building cell, with the entrance polygon moved to world space and the + // inside-side flipped (so the outdoor half-space is "inside" the node). + private static LoadedCell BuildingWithOneExit(uint cellId) + { + var cell = new LoadedCell { CellId = cellId }; + cell.WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f); + cell.InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f); + cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0)); + cell.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(1, 0, 0), D = 0f, InsideSide = 0 }); + cell.PortalPolygons.Add(new[] + { + new Vector3(0, -1, 0), new Vector3(0, 1, 0), new Vector3(0, 1, 2), new Vector3(0, -1, 2) + }); + return cell; + } + + [Fact] + public void Build_FromBuildingExit_AddsReversePortalIntoBuilding() + { + uint outdoorId = 0xA9B40031; + var building = BuildingWithOneExit(0xA9B40170); + var node = OutdoorCellNode.Build(outdoorId, new[] { building }); + + Assert.Equal(outdoorId, node.CellId); + Assert.True(node.SeenOutside); + Assert.Equal(Matrix4x4.Identity, node.WorldTransform); + Assert.Single(node.Portals); + Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId); + // Reversed inside-side: the building's exit was InsideSide=0, the node's is 1. + Assert.Equal(1, node.ClipPlanes[0].InsideSide); + // Entrance polygon moved to world space (building translated +10 X): first vert x≈10. + Assert.Equal(10f, node.PortalPolygons[0][0].X, 3); + } + + [Fact] + public void Build_NoBuildings_ReturnsEmptyPortalNode() + { + var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty()); + Assert.Empty(node.Portals); + Assert.True(node.SeenOutside); + } +} From c5b4f77fe44a161172169f5d238592823c53b81f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:23:28 +0200 Subject: [PATCH 030/172] =?UTF-8?q?test(render):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20Build=20floods=20from=20the=20outdoor=20node=20into=20buildi?= =?UTF-8?q?ngs=20(+cycle=20guard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD characterisation test proving PortalVisibilityBuilder.Build correctly roots at the outdoor cell node (OutdoorCellNode) and floods into adjacent buildings through their entrance portals. No changes to Build or OutdoorCellNode were needed. Key finding: the task spec's building fixture used InsideSide=0 for an exit portal whose building interior is at Y>=5 (Normal=(0,-1,0), D=5). The correct InsideSide is 1 (interior where dot<=0 -> Y>=5); with InsideSide=0 the outdoor camera (Y=-3, dot=8) incorrectly passes as "interior" of the building so OutdoorCellNode.Build's InsideSide flip (0->1) puts the outdoor camera on the wrong side of the gate. Corrected fixture uses InsideSide=1 matching OutdoorCellNodeTests geometry convention (building interior = POSITIVE dot side, outdoor = negative dot side; flip makes outdoor negative-dot side the traversable direction). Both tests pass; full suite 214/214. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/UnifiedFloodTests.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs diff --git a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs new file mode 100644 index 00000000..75323d17 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class UnifiedFloodTests +{ + // Shared building fixture: a building cell whose interior is at Y >= 5. + // The exit portal faces -Y (Normal=(0,-1,0)); interior is where dot<=0 -> Y>=5 (InsideSide=1). + // The outdoor camera is at Y=-3, which is the OUTDOOR side (Y<5). + // OutdoorCellNode.Build flips InsideSide to 0 so the outdoor camera (dot>=0 i.e. Y<5) passes + // the side test and the flood reaches the building. + private static LoadedCell MakeBuildingCell(uint cellId) + { + var building = new LoadedCell { CellId = cellId, SeenOutside = true }; + building.WorldTransform = Matrix4x4.Identity; + building.InverseWorldTransform = Matrix4x4.Identity; + building.Portals.Add(new CellPortalInfo(0xFFFF, 0, 0, 0)); + // InsideSide=1: interior where (0,-1,0)·p+5 <= 0 -> Y>=5 (the building body is at Y>=5). + building.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, -1, 0), D = 5f, InsideSide = 1 }); + building.PortalPolygons.Add(new[] + { + new Vector3(-1, 5, 0), new Vector3(1, 5, 0), new Vector3(1, 5, 2), new Vector3(-1, 5, 2) + }); + return building; + } + + [Fact] + public void Build_RootedAtOutdoorNode_FloodsIntoBuilding() + { + var building = MakeBuildingCell(0xA9B40170); + var node = OutdoorCellNode.Build(0xA9B40031, new[] { building }); + LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null; + + var eye = new Vector3(0, -3, 1); + var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f); + + var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj); + + Assert.Contains(0xA9B40031u, frame.OrderedVisibleCells); // the outdoor node itself + Assert.Contains(0xA9B40170u, frame.OrderedVisibleCells); // flooded into the building + } + + [Fact] + public void Build_OutdoorBuildingCycle_Terminates() + { + // Building's exit portal reciprocally points back near the node; assert Build + // returns (does not hang) and the visible set is bounded/small. + var building = MakeBuildingCell(0xA9B40170); + var node = OutdoorCellNode.Build(0xA9B40031, new[] { building }); + LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building + : (id & 0xFFFFu) == 0x0031 ? node : null; + var eye = new Vector3(0, -3, 1); + var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f); + + var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj); + Assert.True(frame.OrderedVisibleCells.Count < 10); // bounded, no runaway + } +} From 1e9485532f0604fff309f9d2cfdf1b2d9d41f67b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:26:09 +0200 Subject: [PATCH 031/172] =?UTF-8?q?docs:=20plan=20progress=20=E2=80=94=20T?= =?UTF-8?q?asks=201+3=20done=20(outdoor=20node=20+=20outdoor-root=20flood?= =?UTF-8?q?=20validated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation shipped + validated: the flood roots at the outdoor node and reaches buildings with ZERO production changes (the design's central risk is resolved). Next = Task 2 + Phase 3 cutover together, inline (contextual GameWindow surgery ending at the visual gate). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-render-unification-outdoor-as-cell.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md index 88053100..13605a5f 100644 --- a/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md +++ b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md @@ -10,6 +10,25 @@ **Spec:** [docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md](../specs/2026-06-07-render-unification-outdoor-as-cell-design.md) +## Progress (2026-06-07) + +- **Task 1 — `OutdoorCellNode.Build` — DONE** (`2a2cc97`). 2 unit tests; App.Tests 212/212. +- **Task 3 — outdoor-root flood — DONE** (`c5b4f77`, done before Task 2 to de-risk the + core hypothesis). **KEY RESULT: the flood roots at the outdoor node and reaches + buildings with ZERO production changes** — `PortalVisibilityBuilder.Build` and + `OutdoorCellNode.Build` are correct as-is; cycle termination holds. App.Tests 214/214. + (The plan's Task 3 fixture sketch had `InsideSide=0`; the shipped test uses the correct + `InsideSide=1` — building interior at Y>5 is the negative half-space. `OutdoorCellNode` + flips it so the outdoor camera passes the side test.) +- **NEXT: Task 2 + Phase 3 together, INLINE** (not subagent — deeply-contextual GameWindow + render-loop surgery, ends at the visual gate; CLAUDE.md warns against subagent + integration here). Task 2 (build the node each frame) shares the render-loop region with + the Phase 3 cutover, so they're one chunk: extract `GatherNearbyBuildingCells`, resolve + `viewerRoot` to the node outdoors, repoint exit portals to the node in `Build`, unified + draw path, then (after the user's visual gate) delete `BuildFromExterior` / `DrawPortal` / + `OutsideView` / `DrawLandscapeThroughOutsideView`. Do this with fresh context. +- Tree clean; HEAD `c5b4f77`; baselines App 214 / Core 1331-4-1. + **Baselines that must hold:** build 0 errors; App.Tests 210 pass; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Run the client per CLAUDE.md "Running the client"; `+Acdream` spawns at the Holtburg/Arcanum cottage. Launch logs are UTF-16. Use `ACDREAM_PROBE_FLAP` only (NOT `ACDREAM_PROBE_SHELL`). --- From d01fe30ac0bf5e69295fa3b2d7850a27147a735c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:35:25 +0200 Subject: [PATCH 032/172] =?UTF-8?q?feat(render):=20Phase=203=20(Task=202)?= =?UTF-8?q?=20=E2=80=94=20build=20the=20outdoor=20node=20each=20frame=20(a?= =?UTF-8?q?dditive,=20unconsumed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the synthetic outdoor cell node (OutdoorCellNode.Build) every outdoor frame from the nearby building-entrance portals (Chebyshev <=1 landblocks), stored in _outdoorNode. NOT yet rooted — clipRoot/viewerRoot unchanged, so behaviour is identical this commit. [outdoor-node] probe (ACDREAM_PROBE_FLAP) reports the live portal count so the next (cutover) step can confirm real building entrances were found before flipping the render root. App.Tests 214/214, build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 99527c81..1632bd02 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -184,6 +184,14 @@ public sealed class GameWindow : IDisposable private readonly HashSet _outdoorRootNoCells = new(0); private readonly HashSet _exteriorPortalLandblocks = new(); private readonly List _exteriorPortalCandidateCells = new(); + + // Phase 3 (render unification, 2026-06-07): the synthetic outdoor cell node — the outdoor + // world as a flood-graph cell (spec 2026-06-07-render-unification-outdoor-as-cell). Rebuilt + // each outdoor frame from nearby building-entrance portals. ADDITIVE for now (built but not + // yet rooted; the clipRoot flip + OutsideView terrain integration is the cutover step). + private LoadedCell? _outdoorNode; + private readonly List _outdoorNodeBuildingCells = new(); + private readonly HashSet _outdoorNodeSeenLbs = new(); private readonly HashSet _outdoorSceneParticleEntityIds = new(); private readonly HashSet _visibleSceneParticleEntityIds = new(); private string? _lastRenderSignature; @@ -7339,6 +7347,39 @@ public sealed class GameWindow : IDisposable // (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its // binding=2 defensively from the ids we hand it. _clipFrame ??= ClipFrame.NoClip(); + + // Phase 3 (render unification, additive): build the synthetic outdoor cell node when + // the eye is outdoors (no interior viewerRoot). Stored in _outdoorNode but NOT yet + // rooted — behaviour is unchanged this commit. The nearby-building enumeration mirrors + // the look-in candidate gather in the OUTDOOR branch below (Chebyshev <=1 landblocks); + // OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip + + // OutsideView terrain integration that consumes this is the next (cutover) step. + _outdoorNode = null; + if (viewerRoot is null && viewerCellId != 0u) + { + _outdoorNodeBuildingCells.Clear(); + _outdoorNodeSeenLbs.Clear(); + int onGridX = playerLb.HasValue ? (int)((playerLb.Value >> 24) & 0xFFu) : -1; + int onGridY = playerLb.HasValue ? (int)((playerLb.Value >> 16) & 0xFFu) : -1; + foreach (var onEntry in _worldState.LandblockEntries) + { + uint onLb = (onEntry.LandblockId >> 16) & 0xFFFFu; + if (playerLb.HasValue) + { + int gX = (int)((onLb >> 8) & 0xFFu); + int gY = (int)(onLb & 0xFFu); + if (Math.Max(Math.Abs(gX - onGridX), Math.Abs(gY - onGridY)) > 1) continue; + } + if (!_outdoorNodeSeenLbs.Add(onLb)) continue; + foreach (var onCell in _cellVisibility.GetCellsForLandblock(onLb)) + _outdoorNodeBuildingCells.Add(onCell); + } + _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId, _outdoorNodeBuildingCells); + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} portals={_outdoorNode.Portals.Count}")); + } + uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( playerCellId, From 7b3091c44d30a7206806fcafae6c6b096c50f323 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:36:20 +0200 Subject: [PATCH 033/172] =?UTF-8?q?docs:=20plan=20progress=20=E2=80=94=20T?= =?UTF-8?q?ask=202=20done;=20cutover=20flip=20de-risked=20+=20precisely=20?= =?UTF-8?q?specified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell pass is a safe no-op for the node id (no exclusion needed); indoor->outdoor terrain already works via OutsideView; the only new piece is feeding the outdoor ROOT node's full-screen region to OutsideView. Remaining = OutsideView integration (read ClipFrameAssembler) + clipRoot flip + launch + visual gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-render-unification-outdoor-as-cell.md | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md index 13605a5f..330fdbbe 100644 --- a/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md +++ b/docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md @@ -20,14 +20,37 @@ (The plan's Task 3 fixture sketch had `InsideSide=0`; the shipped test uses the correct `InsideSide=1` — building interior at Y>5 is the negative half-space. `OutdoorCellNode` flips it so the outdoor camera passes the side test.) -- **NEXT: Task 2 + Phase 3 together, INLINE** (not subagent — deeply-contextual GameWindow - render-loop surgery, ends at the visual gate; CLAUDE.md warns against subagent - integration here). Task 2 (build the node each frame) shares the render-loop region with - the Phase 3 cutover, so they're one chunk: extract `GatherNearbyBuildingCells`, resolve - `viewerRoot` to the node outdoors, repoint exit portals to the node in `Build`, unified - draw path, then (after the user's visual gate) delete `BuildFromExterior` / `DrawPortal` / - `OutsideView` / `DrawLandscapeThroughOutsideView`. Do this with fresh context. -- Tree clean; HEAD `c5b4f77`; baselines App 214 / Core 1331-4-1. +- **Task 2 — build the outdoor node each frame — DONE** (`d01fe30`). Additive: `_outdoorNode` + built each outdoor frame from nearby building-entrance portals (Chebyshev ≤1), with an + `[outdoor-node]` probe (ACDREAM_PROBE_FLAP) reporting the live portal count. Not yet rooted + → behaviour unchanged. App.Tests 214/214, build green. (Insertion: `GameWindow.cs` just + before the branch at the old line ~7341; `playerLb` is in scope there.) + +- **NEXT — THE CUTOVER FLIP (the remaining risky, launch-gated chunk), INLINE.** Now fully + de-risked by reading the draw path: + - The shell pass is a **safe no-op** for the synthetic node id — `DrawEnvCellShells` → + `_envCells.Render(pass, {id})` renders nothing for an id with no prepared geometry + (`RetailPViewRenderer.cs:190-202`). So **no explicit shell-exclusion is needed.** + - Indoor→outdoor terrain **already works** via the existing `OutsideView` → terrain-slice + path (`DrawInside` → `DrawLandscapeThroughOutsideView`, `RetailPViewRenderer.cs:79,138`). + The ONLY new piece is the **outdoor-ROOT** case: when `DrawInside` is rooted at the + outdoor node, the node's full-screen view region must become an `OutsideView` slice so + terrain draws full-screen. → Read `ClipFrameAssembler` (how `pvFrame.OutsideView` becomes + `OutsideViewSlices`; how a full-screen region maps to a no-clip terrain slice), then in + `PortalVisibilityBuilder.Build` (or `DrawInside`): when the root is the outdoor node + (`SeenOutside` + outdoor id), `AddRegion(frame.OutsideView, )`. + - Then flip: `viewerRoot = _outdoorNode` when outdoors; `clipRoot = viewerRoot` always + (drop the `playerIndoorGate && viewerRoot != null` gate at `GameWindow.cs:~7346`). This + routes EVERY frame through `_retailPViewRenderer.DrawInside` (the `else` outdoor block + becomes dead — leave it for the post-visual-gate delete). + - **Build → launch (`ACDREAM_PROBE_FLAP` only) → USER VISUAL GATE** at the cottage. Then + delete `BuildFromExterior` / `DrawPortal` / the dead `else` block / `OutsideView`-only + plumbing (Task 7) + cleanup (Phase 4). + - **WARNING:** this is coordinated surgery (Build + ClipFrameAssembler + GameWindow) that + ends at a launch + visual gate; a first attempt rarely renders right. Do it with adequate + context headroom (the dead-zone regression came from rushing a render change before a + visual gate). Verify the [outdoor-node] probe shows real portals FIRST. +- Tree clean; HEAD `d01fe30`; baselines App 214 / Core 1331-4-1. **Baselines that must hold:** build 0 errors; App.Tests 210 pass; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Run the client per CLAUDE.md "Running the client"; `+Acdream` spawns at the Holtburg/Arcanum cottage. Launch logs are UTF-16. Use `ACDREAM_PROBE_FLAP` only (NOT `ACDREAM_PROBE_SHELL`). From 9bc0db9351645b350e938faa18ebd6b82a914c6c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 18:42:04 +0200 Subject: [PATCH 034/172] =?UTF-8?q?docs:=20handoff=20=E2=80=94=20render=20?= =?UTF-8?q?unification=20CUTOVER=20FLIP=20(canonical=20pickup)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Super-detailed pickup for the one remaining step that fixes the flap: the cutover flip (terrain via OutsideView for the outdoor root + clipRoot=viewerRoot??_outdoorNode + launch + visual gate + delete old paths). Exact steps, current line numbers, the de-risking already done (shell no-op, flood validated, OutsideView mechanism), the 4 render cases, the Step-B integration checklist, do/don't, and a kickoff prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...render-unification-cutover-flip-handoff.md | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/research/2026-06-07-render-unification-cutover-flip-handoff.md diff --git a/docs/research/2026-06-07-render-unification-cutover-flip-handoff.md b/docs/research/2026-06-07-render-unification-cutover-flip-handoff.md new file mode 100644 index 00000000..13f5188f --- /dev/null +++ b/docs/research/2026-06-07-render-unification-cutover-flip-handoff.md @@ -0,0 +1,283 @@ +# Handoff — Render Unification CUTOVER FLIP (the one step that fixes the flap) — 2026-06-07 + +> **CANONICAL PICKUP. Read this first.** Worktree `thirsty-goldberg-51bb9b`, branch +> `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; launch logs are UTF-16; build before +> launch; **acceptance is the user's eyes** at the Holtburg/Arcanum cottage. Do NOT branch/worktree, +> push, `git stash`/`gc`, or revert the dirty tree (it has pre-existing untracked files — leave them). +> Live ACE `127.0.0.1:9000`, `testaccount`/`testpassword`, char `+Acdream` (spawns at the cottage, +> landblock `0xA9B4`, cottage cells `0xA9B4016F–0175`, outdoor cell id near spawn `0xA9B40031`). + +--- + +## 0. TL;DR — you are ONE step from fixing the flap + +The indoor render **FLAP** (textures "battle"/oscillate at every transition) is the **two-branch +render split** (`OutdoorRoot` vs `RetailPViewInside`) toggling as the 3rd-person eye crosses the +indoor/outdoor boundary. The fix (user-approved): make the **outdoor world a flood-graph cell** so +there is **one** render path (retail's `DrawInside(viewer_cell)`), with **no branch to flip**. + +**~70% is built, validated, and committed.** The remaining step is **the CUTOVER FLIP**: root the one +draw path at the viewer cell (the outdoor node when the eye is outdoors), make terrain draw via the +existing OutsideView mechanism, then **launch → user visual gate → delete the dead old paths.** This +doc gives the exact, de-risked steps. **Do the flip with adequate context headroom — it is coordinated +surgery ending at a launch + visual gate, and a first attempt rarely renders right. Rushing a render +change before a visual gate is how the dead-zone regression happened on the morning of 2026-06-07.** + +--- + +## 1. State — what is committed (branch HEAD `7b3091c`) + +| Commit | What | +|---|---| +| `bb64a67` | Spec: [docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md](../superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md) | +| `06666b7` | Plan: [docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md](../superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md) (Progress section is current) | +| `2a2cc97` | **Task 1** — `OutdoorCellNode.Build` (`src/AcDream.App/Rendering/OutdoorCellNode.cs`) + 2 tests | +| `c5b4f77` | **Task 3** — outdoor-root flood VALIDATED (`tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs`) | +| `d01fe30` | **Task 2** — outdoor node built live each frame, additive (`_outdoorNode` in `GameWindow.cs`) | +| `7b3091c` | plan progress (cutover de-risked) | + +**Baselines (MUST hold):** build 0 errors; App.Tests **214** pass; Core.Tests **1331 pass / 4 fail +(pre-existing door/step-up: 2× DoorBugTrajectoryReplay LiveCompare, BSPStepUpTests.D4, +DoorCollisionApparatus) / 1 skip**. Tree: no uncommitted tracked changes; pre-existing untracked +files (`*.txt/*.png/*.jsonl/*.py/*.log/*.ps1`, `lip-cells/`) are NOT ours — leave them. + +**Verify on pickup:** `git log --oneline -6` shows the above; `dotnet build -c Debug` green; +`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Debug` → 214/0. + +--- + +## 2. Why this design (don't relitigate — these are evidence-disproven) + +The flap was pinned 2026-06-07 with live `ACDREAM_PROBE_FLAP` `[render-sig]`. The branch at +**`GameWindow.cs:7384-7388`** picks the path: +```csharp +bool playerIndoorGate = RenderingDiagnostics.ShouldRenderIndoor(playerCellId, playerRoot is not null); +var clipRoot = playerIndoorGate && viewerRoot is not null ? viewerRoot : null; // line 7387 +string renderBranch = clipRoot is null ? "OutdoorRoot" : "RetailPViewInside"; +``` +`viewerRoot` is null when the eye is outdoors → `clipRoot` null → `OutdoorRoot`. The two branches draw +differently (terrain full vs door-clipped; 4 look-in cells vs 6 flood cells; depth-clear on/off), so the +eye crossing the boundary toggles them → the flap. **When the eye stays indoor (`0170`↔`0171`) BOTH +draw the same 6 cells → no flap** — proving it's specifically the indoor/outdoor branch switch. + +**DO NOT retry (all failed/dead-ends, with evidence):** +- **Viewer-cell dead-zone** (±0.2 mm in `PointInsideCellBsp`): the eye crosses by METRES; zero effect; + it REGRESSED the cellar roof (shifted the flood root via the pick). Reverted `2a2cc97`'s predecessor. +- **Gating the branch on the PLAYER cell**: documented dead-end at `GameWindow.cs:7207-7211` — forcing an + indoor draw while the camera is outside "drops the outdoor pass and leaves clear color around a floating + doorway slice." When the eye is genuinely outside, the outdoor view IS correct. +- **Render-side debounce/grace** on the branch: forbidden (no-workarounds rule). +- Part 1 (camera boom snap, `d2212cf`) + Part 3 (w-space portal clip, `ProjectToClip`/`ClipToRegion`) are + ALREADY shipped — the 2026-06-05 3-part viewer-cell-stability plan is exhausted. + +Full root-cause memory: `project_indoor_flap_rootcause`. Retail oracle: `SmartBox::RenderNormalMode` +(`0x00453aa0`, pc:92635) → `RenderDeviceD3D::DrawInside` (`0x0059f0d0`) → `PView::DrawInside` +(`0x005a5860`, pc:433793). Retail ALWAYS calls `DrawInside(viewer_cell)`; the outdoor world is a cell +whose stab list carries the landscape. ONE path, no inside/outside branch. + +--- + +## 3. What's already built + VALIDATED (so you trust it) + +- **`OutdoorCellNode.Build(uint outdoorCellId, IReadOnlyList nearbyBuildingCells)`** + (`src/AcDream.App/Rendering/OutdoorCellNode.cs`) → a `LoadedCell` with `WorldTransform=Identity`, + `SeenOutside=true`, and `Portals`/`ClipPlanes`/`PortalPolygons` that point BACK into each building + cell (reverse of the building's `OtherCellId==0xFFFF` exit portal; entrance polygon → world space; + `InsideSide` flipped). Unit-tested. +- **The flood roots at the outdoor node with ZERO production changes** (Task 3, + `UnifiedFloodTests.cs`): `PortalVisibilityBuilder.Build(node, eye, lookup, viewProj)` returns the node + + the buildings reached through its portals; the outdoor↔building cycle terminates (existing `queued` + HashSet + `MaxReprocessPerCell`). **This is the de-risk: the core hypothesis is proven.** +- **`_outdoorNode` is built live each outdoor frame** (Task 2, `GameWindow.cs` just before the branch, + ~line 7360) from nearby building cells (Chebyshev ≤1 landblocks). It is **NOT yet consumed** (behaviour + unchanged). An `[outdoor-node]` probe (under `ACDREAM_PROBE_FLAP`) prints + `cell=0x.. nearbyCells=N portals=M`. + +--- + +## 4. THE FLIP — exact steps (in order). Each builds green; the launch is the gate. + +### Pre-flight (do FIRST — confirms the node finds real entrances) +Launch with `ACDREAM_PROBE_FLAP=1` (see §6), stand at the cottage, read the log: +``` +Get-Content launch-*.log | Select-String "outdoor-node" -SimpleMatch | Select-Object -Last 5 +``` +**Expect `portals=M` with M ≥ 1** when standing outside near the cottage (the node found the cottage's +exit portals). If `portals=0` everywhere, STOP — the nearby-building enumeration or the exit-portal +detection is wrong; fix that before flipping (the flip is pointless if the node has no doorways). + +### Step A — terrain for the outdoor-ROOT case (the only genuinely new draw code) +Indoor→outdoor terrain ALREADY works via the OutsideView→terrain-slice path +(`RetailPViewRenderer.DrawInside` line 79 → `DrawLandscapeThroughOutsideView` line 138; the assembler +turns `pvFrame.OutsideView.Polygons` into `OutsideViewSlices` at `ClipFrameAssembler.cs:134-165`; +`outdoorVisible = OutsideViewSlices.Length > 0` → terrain draws). The ONLY new piece: when `Build` is +rooted at the outdoor node, **outdoors is visible full-screen**, so add a **full-screen region to +`frame.OutsideView`**. + +In **`PortalVisibilityBuilder.Build`** (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:63`), right +after the root is seeded full-screen (`frame.CellViews[cameraCell.CellId] = CellView.FullScreen()`, ~line +77), add: +```csharp +// Render unification: an OUTDOOR root (synthetic outdoor node, low cell id < 0x100) sees outdoors +// FULL-SCREEN. Feed that to OutsideView so DrawLandscapeThroughOutsideView draws the landscape as the +// node's shell (full-screen here; the doorway region when an interior root reaches outdoors via an exit +// portal — that path already exists at the OtherCellId==0xFFFF branch below). +if ((cameraCell.CellId & 0xFFFFu) < 0x0100u) + AddRegion(frame.OutsideView, /* full-screen region */); +``` +**You must confirm the exact full-screen call.** Read `CellView.FullScreen()` and `AddRegion(...)` in +`PortalView.cs` / `PortalVisibilityBuilder.cs`. `AddRegion(CellView, List<...>)` takes a region (list of +NDC polygons); the root seed uses `CellView.FullScreen()`. The full-screen NDC quad is +`[(-1,-1),(1,-1),(1,1),(-1,1)]`. Use whatever representation `AddRegion`/`CellView` expects (mirror how +`CellView.FullScreen()` builds its polygon). `ClipFrameAssembler` handles a screen-covering OutsideView +poly as either 4 edge planes (clips nothing) or `cps.Count==0` → scissor fallback (full-screen) — both +yield `terrainMode != Skip` → terrain draws everywhere. Either is fine. + +**Alternative if the OutsideView call proves fiddly (fallback, less unified but lower-risk):** in +`GameWindow`, when `clipRoot` is the outdoor node, draw terrain full-screen BEFORE `DrawInside` (the way +the old `else` block does at the current `_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb)` +call), and let `DrawInside` draw only the flooded building shells. Prefer the OutsideView approach (one +mechanism); use this only if blocked. + +Build green. (No behaviour change yet — nothing roots at the node until Step B.) + +### Step B — flip the routing (the behaviour change) +At **`GameWindow.cs:7387`** replace the gate so the eye's cell always roots the one path: +```csharp +// Render unification: ONE path rooted at the viewer cell. Eye indoors → its interior cell; eye +// outdoors → the synthetic outdoor node (built above). No inside/outside branch → no flap. +var clipRoot = viewerRoot ?? _outdoorNode; +``` +i.e. drop `playerIndoorGate &&` and fall back to `_outdoorNode`. Keep `renderBranch` for the probe +(`clipRoot is null ? "OutdoorRoot" : "RetailPViewInside"` — now `OutdoorRoot` only when `_outdoorNode` is +also null, e.g. legacy camera). The `else` (outdoor) block becomes **dead** when `clipRoot` is non-null — +**leave it for now** (delete in Step D after the visual gate). + +**The 4 cases this produces (all one path, no flap):** +- player out / eye out → root = node → terrain full + flood into visible buildings (the look-in). ✓ +- player in / eye in → root = interior cell → flood + terrain through door (as today). ✓ +- **player in / eye out (the flap case)** → root = node → terrain + flood into the building incl. the + player's cell. Same path as case 1 → no flap. ✓ +- player out / eye in (eye pokes through a doorway) → root = interior cell → drawn from inside. ✓ + +### Step B integration checklist (verify each — these are where it can "screw up") +- **`ComputeVisibilityFromRoot(viewerRoot, ...)`** at `GameWindow.cs:7204` returns null for a null root. + After the flip you pass `clipRoot` (= node) into `DrawInside` via `RootCell`, but the separate + `visibility = ComputeVisibilityFromRoot(viewerRoot, ...)` call still uses `viewerRoot` (the interior + one). Decide: either also feed the node to that call, or confirm `cameraInsideCell`/`rootSeenOutside` + still behave. `rootSeenOutside = viewerRoot?.SeenOutside ?? true` (line 7211) → with the node it'd be + `true` (node.SeenOutside) IF you point it at the node; with the interior `viewerRoot` (null outdoors) + it's `true`. Either way `renderSky` (line 7314 `viewerRoot is null || rootSeenOutside`) stays true + outdoors. **Verify sky still draws outdoors after the flip.** +- **`DrawInside` is rooted at `clipRoot`** (`RetailPViewDrawContext.RootCell = clipRoot`, line 7455) — + already correct; it just now receives the node sometimes. +- **Shell pass is a safe no-op for the node** (`DrawEnvCellShells` → + `_envCells.Render(pass, {nodeId})` renders nothing for an id with no prepared EnvCell geometry, + `RetailPViewRenderer.cs:190-202`). No exclusion needed — confirmed. +- **`PrepareRenderBatches(filter: drawableCells)`** will include the node id; it should no-op for an + unknown EnvCell id. Confirm no throw. +- **Entities**: `InteriorEntityPartition.Partition(drawableCells, ...)` with the node id in the set — + outdoor scenery/buildings are entities; confirm they still draw (membership-gated). The old `else` + block drew outdoor entities via `_interiorRenderer.DrawEntityBucket(... outdoorPartition.Outdoor ...)` + — make sure outdoor entities still draw under the unified path (they may need the node id in their + membership, or a dedicated outdoor bucket draw inside the DrawInside path). + +### Step C — BUILD → LAUNCH → USER VISUAL GATE (do not skip; do not delete anything yet) +`dotnet build -c Debug` green, then launch (`ACDREAM_PROBE_FLAP=1`, §6). **Hand to the user** at the +cottage: walk in/out, pan the camera at the threshold, cellar down/up, look at the cottage from outside. +**Acceptance:** no flap; no missing wall/roof textures; terrain + sky correct; no see-through walls; +pure-outdoor FPS unchanged. Capture `[render-sig]`: `branch` should be `RetailPViewInside` continuously +(no `OutdoorRoot` toggling) and `viewerCell`/`draw` transition cleanly with no 4↔6 cell-set jump. +**If broken, iterate Steps A/B — do NOT proceed to deletes.** + +### Step D — only AFTER the user confirms: delete the dead paths (Task 7 + Phase 4) +Delete `PortalVisibilityBuilder.BuildFromExterior`; `RetailPViewRenderer.DrawPortal`; the dead `else` +(outdoor) block in `GameWindow` (the look-in enumeration + `_exteriorPortalCandidateCells` plumbing + +`DrawPortal` call); and, if now unused, the `OutsideView`-only helpers. Reconcile the `[render-sig]` +probe (`GameWindow.cs:~9039-9082`) to the single path (drop `extPortal/extIds/outdoorRoot*`). Build +green; tests baseline. Update memory `project_indoor_flap_rootcause` + `reference_render_pipeline_state` ++ the roadmap/milestones with the shipped outcome. Commit per step. + +--- + +## 5. Pure-outdoor regression guard (spec §10 — don't skip) +The open-world case (no building in view) MUST stay byte-identical to today: full-screen terrain, no +clip. After Step A/B, when the outdoor node has **zero** portals (no building nearby), the flood is just +`{node}` and OutsideView is the full-screen region → terrain draws full-screen, no interior cells → same +as today. Add/keep a unit test asserting: `Build(emptyPortalNode, ...)` → `OrderedVisibleCells == {node}` +and OutsideView is full-screen (so `terrainMode != Skip`). Visual-gate the open field too, not just the +cottage. + +--- + +## 6. Launch (PowerShell; UTF-16 log; background) +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1"; $env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_PROBE_FLAP = "1" # ONLY this probe. NOT ACDREAM_PROBE_SHELL (it stalls on I/O). +dotnet build -c Debug # MUST be green before launch +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-flip.log" +``` +Run in the background; give it ~12 s to reach in-world. Read with `Get-Content launch-flip.log -Tail N` +and `Select-String`. The client exits cleanly (exit 0) when the user closes the window → ACE session +clears. Probes: `[outdoor-node]` (node portal count), `[render-sig]` (branch/viewer/player/draw/miss per +frame), `[flap-sweep]` (camera sweep). RLE the `viewerCell`/`branch` sequences to check for clean +monotonic transitions vs toggling. + +--- + +## 7. Files & anchors (current line numbers, HEAD 7b3091c) +- `GameWindow.cs`: `_outdoorNode` field + helpers ~line 188; node build ~7355-7380 (before the branch); + `viewerRoot`/`viewerCellId` resolve 7192-7204; `clipRoot`/`renderBranch` **7384-7388** (the flip); + indoor `DrawInside` block 7448-7515; dead-after-flip `else` (outdoor) block 7516-7614 (terrain `_terrain?.Draw` + ~7425, look-in `DrawPortal` ~7570); `DrawRetailPViewLandscapeSlice` ~9239; `[render-sig]` emit ~9039-9082. +- `RetailPViewRenderer.cs`: `DrawInside` 39; `DrawPortal` 88 (delete in D); `DrawLandscapeThroughOutsideView` + 138; `DrawEnvCellShells` 180 (node no-op); shells use `_envCells.Render(pass, {id})`. +- `PortalVisibilityBuilder.cs`: `Build` 63 (root seed ~77 → add full-screen OutsideView for outdoor root); + exit-portal branch 234 (`OtherCellId==0xFFFF` → `AddRegion(frame.OutsideView, ...)` — the indoor→outdoor + path that already works); `BuildFromExterior` 339 (delete in D); `CameraOnInteriorSide` 664. +- `ClipFrameAssembler.cs`: `Assemble` 78; OutsideView→slices 134-165 (`outdoorVisible = slices.Length>0`). +- `OutdoorCellNode.cs`: `Build`. `CellVisibility.cs`: `LoadedCell` (class, `CellId` field line 29), + `CellPortalInfo`/`PortalClipPlane`, `TryGetCell` 276, `GetCellsForLandblock` 266 (returns + `IReadOnlyList`), `ComputeVisibilityFromRoot` 338 (null root → null). + +--- + +## 8. DO / DON'T +**DO:** flip in order A→B→C (gate)→D; build green between steps; verify `[outdoor-node] portals≥1` BEFORE +flipping; keep the `else` block until the user confirms; keep the pure-outdoor case byte-identical. +**DON'T:** retry dead-zone / player-cell branch-gating / debounce (§2); delete the old paths before the +visual gate; switch the FLOOD root to the player cell (root at the VIEWER cell — the node when eye +outdoors); use `ACDREAM_PROBE_SHELL` (I/O stall); rush the flip on low context (visual-gated render +surgery — the dead-zone regression came from exactly that). + +--- + +## 9. Copy-paste kickoff prompt +``` +Continue acdream M1.5 render unification: do the CUTOVER FLIP that fixes the indoor FLAP. Worktree +thirsty-goldberg-51bb9b, branch claude/thirsty-goldberg-51bb9b. PowerShell; launch logs UTF-16; build +before launch; acceptance is the user's eyes at the Holtburg cottage. Do NOT branch/worktree, push, +git stash/gc, or revert the dirty tree. + +READ FIRST: docs/research/2026-06-07-render-unification-cutover-flip-handoff.md (THIS doc — exact steps, +de-risking, do-not list). Then the spec (2026-06-07-render-unification-outdoor-as-cell-design.md) and the +plan Progress section (2026-06-07-render-unification-outdoor-as-cell.md). + +State: ~70% built + validated (HEAD 7b3091c). Outdoor node builder (2a2cc97), outdoor-root flood proven +with zero prod changes (c5b4f77), node built live each frame (d01fe30). Baselines: App 214, Core 1331/4/1, +build green. + +DO THE FLIP (handoff §4), in order, building green between steps: A) feed a full-screen region to +frame.OutsideView when Build roots at the outdoor node ((CellId & 0xFFFF) < 0x100) so terrain draws +full-screen — confirm the exact CellView.FullScreen()/AddRegion call; B) at GameWindow.cs:7387 flip to +`clipRoot = viewerRoot ?? _outdoorNode` (drop the playerIndoorGate gate) — work the Step-B integration +checklist (sky, ComputeVisibilityFromRoot, outdoor entities); C) build → launch (ACDREAM_PROBE_FLAP only) +→ USER VISUAL GATE at the cottage; D) ONLY after the user confirms, delete BuildFromExterior/DrawPortal/ +the dead else block/OutsideView-only plumbing + cleanup. Pre-flight: verify [outdoor-node] portals≥1 +before flipping. Keep the pure-outdoor case byte-identical (regression guard, §5). + +DON'T (§2/§8): retry dead-zone / player-cell branch-gating / debounce (evidence-disproven); delete old +paths before the visual gate; root the flood at the player cell; use ACDREAM_PROBE_SHELL. +``` From 5379f6ecd3fe2fbbff687346b2d648ea87711f17 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 19:06:13 +0200 Subject: [PATCH 035/172] =?UTF-8?q?feat(render):=20Phase=203=20(Step=20A)?= =?UTF-8?q?=20=E2=80=94=20outdoor-root=20seeds=20full-screen=20OutsideView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render unification cutover, Step A (additive, behavior-neutral until Step B). When PortalVisibilityBuilder.Build roots at the synthetic outdoor node, seed OutsideView with the full-screen NDC quad so ClipFrameAssembler yields a full-screen OutsideView slice and DrawInside's DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's shell — the same callback that already draws the doorway slice for an interior root looking out. Keyed on a new explicit LoadedCell.IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior ids, so an id test misfired on 4 existing PortalVisibilityBuilderTests. Nothing roots at the node until Step B, so this is behavior-neutral. Tests: App 216/0 (2 new UnifiedFloodTests incl. the spec section 10 pure-outdoor regression guard + 2 OutdoorCellNode flag assertions). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/CellVisibility.cs | 10 ++++ src/AcDream.App/Rendering/OutdoorCellNode.cs | 1 + .../Rendering/PortalVisibilityBuilder.cs | 12 +++++ .../Rendering/OutdoorCellNodeTests.cs | 2 + .../Rendering/UnifiedFloodTests.cs | 46 +++++++++++++++++++ 5 files changed, 71 insertions(+) diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index 80477c0e..7cabee61 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -104,6 +104,16 @@ public sealed class LoadedCell /// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test. /// public bool SeenOutside; + + /// + /// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by + /// — the outdoor world modelled as a flood-graph cell whose + /// shell is the landscape. seeds OutsideView + /// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell). + /// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but + /// test fixtures use low ids for interior cells, so keying on the id would misfire. + /// + public bool IsOutdoorNode; } /// diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs index 0830f096..6ce08e3a 100644 --- a/src/AcDream.App/Rendering/OutdoorCellNode.cs +++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs @@ -20,6 +20,7 @@ public static class OutdoorCellNode { CellId = outdoorCellId, SeenOutside = true, + IsOutdoorNode = true, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, }; diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 461d1145..e421e3a4 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -76,6 +76,18 @@ public static class PortalVisibilityBuilder frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); + // Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor + // node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC + // quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's + // DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" — + // the very same callback that already draws the doorway slice when an INTERIOR root reaches + // outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a + // cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior + // ids, so an id test would misfire. An interior root never sets this flag, so the indoor + // exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region. + if (cameraCell.IsOutdoorNode) + frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone())); + // Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first; // each cell carries the camera→nearest-portal-vertex distance that put it on the list // (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs index 1129b295..17faa55e 100644 --- a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs @@ -33,6 +33,7 @@ public class OutdoorCellNodeTests Assert.Equal(outdoorId, node.CellId); Assert.True(node.SeenOutside); + Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on Assert.Equal(Matrix4x4.Identity, node.WorldTransform); Assert.Single(node.Portals); Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId); @@ -48,5 +49,6 @@ public class OutdoorCellNodeTests var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty()); Assert.Empty(node.Portals); Assert.True(node.SeenOutside); + Assert.True(node.IsOutdoorNode); } } diff --git a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs index 75323d17..4beede61 100644 --- a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs @@ -60,4 +60,50 @@ public class UnifiedFloodTests var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj); Assert.True(frame.OrderedVisibleCells.Count < 10); // bounded, no runaway } + + // Step A (cutover): rooting at the outdoor node must seed OutsideView with the FULL-SCREEN NDC + // quad so DrawLandscapeThroughOutsideView draws the landscape as the node's shell. Without this + // the outdoor-root frame would have OutsideViewSlices.Length==0 -> TerrainMode.Skip -> no terrain. + [Fact] + public void Build_RootedAtOutdoorNode_SeedsFullScreenOutsideView() + { + var building = MakeBuildingCell(0xA9B40170); + var node = OutdoorCellNode.Build(0xA9B40031, new[] { building }); + LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null; + + var eye = new Vector3(0, -3, 1); + var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f); + + var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj); + + Assert.False(frame.OutsideView.IsEmpty); + // The union bounds of OutsideView must cover the whole NDC viewport (a full-screen quad), + // so ClipFrameAssembler yields TerrainMode != Skip and the terrain draws everywhere. + Assert.Equal(-1f, frame.OutsideView.MinX, 3); + Assert.Equal(-1f, frame.OutsideView.MinY, 3); + Assert.Equal(1f, frame.OutsideView.MaxX, 3); + Assert.Equal(1f, frame.OutsideView.MaxY, 3); + } + + // Pure-outdoor regression guard (spec section 10): an outdoor node with NO nearby buildings must + // resolve to exactly {node} with a full-screen OutsideView -> full-screen terrain, no interior + // cells, byte-for-byte today's open-world draw. Visual-gate the open field, not just the cottage. + [Fact] + public void Build_EmptyOutdoorNode_FullScreenOutsideView_OnlyNodeVisible() + { + var node = OutdoorCellNode.Build(0xA9B40031, Array.Empty()); + Assert.Empty(node.Portals); // no doorways + + var eye = new Vector3(0, -3, 1); + var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f); + + var frame = PortalVisibilityBuilder.Build(node, eye, _ => null, view * proj); + + Assert.Equal(new[] { 0xA9B40031u }, frame.OrderedVisibleCells); // only the node + Assert.False(frame.OutsideView.IsEmpty); + Assert.Equal(-1f, frame.OutsideView.MinX, 3); + Assert.Equal(1f, frame.OutsideView.MaxX, 3); + } } From 445e861163d0a3db87b957a2bd2c19e15c57c0c1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 19:06:27 +0200 Subject: [PATCH 036/172] =?UTF-8?q?feat(render):=20Phase=203=20(Step=20B)?= =?UTF-8?q?=20=E2=80=94=20single=20render=20path=20rooted=20at=20the=20vie?= =?UTF-8?q?wer=20cell=20(cutover=20flip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CUTOVER FLIP that kills the indoor FLAP. Replace the two-branch gate (clipRoot = playerIndoorGate && viewerRoot ? viewerRoot : null) with clipRoot = viewerRoot ?? _outdoorNode, so EVERY frame routes through the one RetailPViewRenderer.DrawInside path rooted at the viewer cell — interior EnvCell when the eye is indoors, the synthetic outdoor node when outdoors. There is no inside/outside branch to toggle as the 3rd-person eye crosses the doorway, so the flap (textures battling at every transition) dies by construction. Matches retail SmartBox::RenderNormalMode -> DrawInside(viewer_cell) (0x453aa0 -> 0x5a5860). clipRoot is null only pre-spawn/login (viewerCellId==0 -> _outdoorNode null), so the outdoor LScape block still runs as the safety path and login keeps its live sky. playerIndoorGate stays computed for the [render-sig] probe. Preserve the LiveDynamic entity draw (server entities with no resolved ParentCellId — the transient unpositioned case) for the outdoor-node root: the old outdoor branch drew it; DrawInside does not, so re-issue it after DrawInside to keep the spec section 10 byte-identical-outdoor guarantee (no live entity blinks out). The old outdoor else block + DrawPortal/BuildFromExterior are now dead when clipRoot is non-null but are LEFT IN PLACE for the user visual gate (handoff section 4 Step D deletes them only after the user confirms). App 216/0, build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1632bd02..481eb6fe 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7384,7 +7384,16 @@ public sealed class GameWindow : IDisposable bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( playerCellId, playerRoot is not null); - var clipRoot = playerIndoorGate && viewerRoot is not null ? viewerRoot : null; + // Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the + // VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the + // synthetic outdoor node (_outdoorNode, built above from nearby building entrances). The + // result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) -> + // the outdoor LScape block below still runs as the safety path (and login still shows the + // live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the + // doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays + // computed for the [render-sig] probe but no longer selects the path (handoff + // docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B). + var clipRoot = viewerRoot ?? _outdoorNode; string renderBranch = clipRoot is null ? "OutdoorRoot" : "RetailPViewInside"; @@ -7553,6 +7562,23 @@ public sealed class GameWindow : IDisposable : sigSceneParticles; sigOutdoorSceneryDrawn = pviewResult.Partition.Outdoor.Count > 0 && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + + // Render unification: DrawInside draws the Outdoor bucket (through the landscape + // slice) and the per-cell ByCell buckets, but NOT LiveDynamic — server entities with + // no resolved ParentCellId (the transient just-spawned / unpositioned case the old + // outdoor branch drew at the bottom of its block). Preserve that draw for the + // outdoor-node root so no live entity blinks out outdoors (spec section 10 regression + // guard). DrawInside's tail clears entity clip routing and disables clip distances, so + // visibleCellIds:null draws them unclipped — identical to the old outdoor path. + if (ReferenceEquals(clipRoot, _outdoorNode) + && _interiorRenderer is not null + && pviewResult.Partition.LiveDynamic.Count > 0) + { + _interiorRenderer.DrawEntityBucket( + camera, frustum, playerLb, animatedIds, + pviewResult.Partition.LiveDynamic, visibleCellIds: null); + sigLiveDynamicDrawnCount = pviewResult.Partition.LiveDynamic.Count; + } } else { From 88caa0dc8b783f234fefcc2f1e9f14a351cef835 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 20:18:33 +0200 Subject: [PATCH 037/172] fix(render): outdoor-root skips the full-screen depth clear (cellar/buildings no longer draw over the world) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover-flip follow-up (issues 1+2 from the visual gate). After the flip an outdoor-node frame ran DrawInside with a full-screen OutsideView slice (Step A) whose ClearDepthSlice wiped the ENTIRE depth buffer AFTER terrain/exteriors/player drew — so the flooded building interior shells (cellars) drew over everything: the cellar painted in front of the player from outside, and distant building interiors showed through the ground in the open field. The depth clear is a doorway look-in trick (clear a small door region so the cell seen through it draws over the terrain drawn through it). It is wrong for the full-screen base terrain of the outdoor root. Skip it there (ClearDepthSlice=null when clipRoot is the outdoor node); interiors now depth-test against terrain + exteriors and appear only through real door openings. Interior roots keep the doorway clear unchanged. App 216/0, build green. Visual gate pending (player must be outdoors to exercise the outdoor root). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 481eb6fe..a1a96a34 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7527,13 +7527,24 @@ public sealed class GameWindow : IDisposable renderSky, kf, environOverrideActive), - ClearDepthSlice = slice => - { - bool zc = BeginDoorwayScissor(true, slice.NdcAabb); - _gl.Clear(ClearBufferMask.DepthBufferBit); - if (zc) - _gl.Disable(EnableCap.ScissorTest); - }, + // The depth clear is a doorway "look-in" trick: clear depth inside a door/window + // region so the cell seen THROUGH it draws over the terrain drawn through that + // region (the indoor root looking out). For the OUTDOOR-node root the only + // OutsideView slice is the FULL-SCREEN base terrain, so clearing its depth wipes the + // entire depth buffer AFTER terrain/exteriors/player drew — the flooded building + // interiors (cellars) would then paint over everything (cellar in front of the + // player; building interiors through the ground). Outdoors the interiors must + // depth-test against terrain+exteriors and appear only through real door openings, + // so issue NO depth clear. Interior roots keep the doorway clear (unchanged). + ClearDepthSlice = ReferenceEquals(clipRoot, _outdoorNode) + ? null + : slice => + { + bool zc = BeginDoorwayScissor(true, slice.NdcAabb); + _gl.Clear(ClearBufferMask.DepthBufferBit); + if (zc) + _gl.Disable(EnableCap.ScissorTest); + }, DrawCellParticles = sliceCtx => DrawRetailPViewCellParticles(sliceCtx, camera, camPos), EmitDiagnostics = result => From 0030dacaaaa5144a0ce2e87f48460ffe7db2be31 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 21:08:43 +0200 Subject: [PATCH 038/172] fix(render): outdoor look-in draws interior cells only through real doorway apertures (no see-through walls) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover-flip follow-up: see-through buildings from outside. When the outdoor-node flood reaches a building, each interior cell is meant to draw clipped to its doorway aperture. But DrawEnvCellShells falls back to the no-clip slot 0 (full-screen) when a cell's aperture degenerates — screen-covering when you get close, or edge-on. Indoors that fallback is load-bearing (it seals the room the camera stands in; near walls hide the over-draw). From OUTSIDE it paints the building interior across the whole screen, depth-tested, so it shows wherever the solid exterior does not cover — the see-through walls, appearing 'past a threshold' exactly where the aperture degenerates. Fix: for the outdoor-node root only, skip a flooded interior cell with no real plane-clip slot (HasRealClipSlot). From outside, 'no real aperture' means 'do not paint this interior', not 'paint it everywhere'. Interior roots keep the seal-everything slot-0 fallback unchanged. Applied to DrawEnvCellShells AND DrawCellObjectLists so a skipped cell shows neither walls nor furniture; the dead DrawPortal exterior look-in gets the same gate. Root cause traced over the WB EnvCell render path: CellMesh.cs is physics-only; ObjectMeshManager.PrepareCellStructMeshData builds double-sided walls, so this was never a culling bug. App 216/0, build green. Visual gate pending. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/RetailPViewRenderer.cs | 56 ++++++++++++++++--- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 024ec3d9..e7c0e0a2 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -76,11 +76,18 @@ public sealed class RetailPViewRenderer ctx.EmitDiagnostics?.Invoke(result); + // Render unification: from the OUTDOOR-node root a flooded interior cell must draw ONLY within + // its real doorway aperture (a plane-clip slot). A cell whose aperture degenerates to the + // no-clip slot 0 (screen-covering / edge-on) would otherwise draw FULL-SCREEN and paint the + // building interior across the whole screen — the see-through-walls bug. Indoor roots keep the + // seal-everything slot-0 fallback (load-bearing: it seals the room the camera stands in). + bool outdoorRoot = ctx.RootCell.IsOutdoorNode; + DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, outdoorRoot); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, outdoorRoot); return result; } @@ -128,8 +135,11 @@ public sealed class RetailPViewRenderer ctx.EmitDiagnostics?.Invoke(result); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + // DrawPortal is the exterior look-in (camera outside, peering in) → same outdoor gate: an + // interior cell with no real doorway aperture must not draw full-screen. (This path is dead + // after the cutover flip; kept compiling until the Step-D deletion.) + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, outdoorRoot: true); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, outdoorRoot: true); RestoreNoClip(ctx.SetTerrainClipUbo); return result; @@ -181,15 +191,19 @@ public sealed class RetailPViewRenderer IRetailPViewCellDrawCallbacks ctx, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, - HashSet drawableCells) // param kept this task; removed in Task 4 + HashSet drawableCells, // param kept this task; removed in Task 4 + bool outdoorRoot) { // Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list - // (far→near), per portal_view slice. No drawableCells filter — a cell without a - // clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped - // (sealed; per-slice trim returns in Task 4). + // (far→near), per portal_view slice. Indoors a cell without a clip-slot falls through + // GetCellSlicesOrNoClip to NoClipSlice and draws unclipped (sealed — load-bearing R1 seal). + // Outdoors (outdoor-node root) that same unclipped draw IS the see-through bug, so a cell with + // no real plane-clip aperture is skipped entirely (see DrawInside). foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { uint cellId = entry.CellId; + if (outdoorRoot && !HasRealClipSlot(clipAssembly, cellId)) + continue; _oneCell.Clear(); _oneCell.Add(cellId); @@ -207,7 +221,8 @@ public sealed class RetailPViewRenderer PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, - InteriorEntityPartition.Result partition) + InteriorEntityPartition.Result partition, + bool outdoorRoot) { for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { @@ -215,6 +230,11 @@ public sealed class RetailPViewRenderer if (!drawableCells.Contains(cellId)) continue; + // Outdoor-node root: skip a cell with no real doorway aperture (would draw full-screen) — + // matches DrawEnvCellShells so a skipped interior cell shows neither walls nor furniture. + if (outdoorRoot && !HasRealClipSlot(clipAssembly, cellId)) + continue; + if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) continue; @@ -240,6 +260,24 @@ public sealed class RetailPViewRenderer return new[] { NoClipSlice }; } + /// + /// True iff has at least one view slice backed by a real plane-clip + /// slot (slot != 0) in this frame's assembly — i.e. a genuine doorway aperture. A cell with no + /// entry, or only the no-clip slot 0 (screen-covering / degenerate aperture → scissor fallback), + /// returns false. Used by the outdoor-node root to refuse drawing an interior cell that would + /// otherwise paint full-screen (the see-through-walls bug). Slot 0 is reserved as no-clip + /// (), so "real aperture" is precisely "some slot != 0". + /// + private static bool HasRealClipSlot(ClipFrameAssembly clipAssembly, uint cellId) + { + if (!clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)) + return false; + foreach (var slice in slices) + if (slice.Slot != 0) + return true; + return false; + } + private void UseIndoorMembershipOnlyRouting() { // Retail's PView portal views decide which cells/objects are eligible, From 774cb227136d8908d20b2103006a110d03d0f7ca Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 21:16:11 +0200 Subject: [PATCH 039/172] Revert "fix(render): outdoor look-in draws interior cells only through real doorway apertures (no see-through walls)" This reverts commit 0030dacaaaa5144a0ce2e87f48460ffe7db2be31. --- .../Rendering/RetailPViewRenderer.cs | 56 +++---------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index e7c0e0a2..024ec3d9 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -76,18 +76,11 @@ public sealed class RetailPViewRenderer ctx.EmitDiagnostics?.Invoke(result); - // Render unification: from the OUTDOOR-node root a flooded interior cell must draw ONLY within - // its real doorway aperture (a plane-clip slot). A cell whose aperture degenerates to the - // no-clip slot 0 (screen-covering / edge-on) would otherwise draw FULL-SCREEN and paint the - // building interior across the whole screen — the see-through-walls bug. Indoor roots keep the - // seal-everything slot-0 fallback (load-bearing: it seals the room the camera stands in). - bool outdoorRoot = ctx.RootCell.IsOutdoorNode; - DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, outdoorRoot); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, outdoorRoot); + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); return result; } @@ -135,11 +128,8 @@ public sealed class RetailPViewRenderer ctx.EmitDiagnostics?.Invoke(result); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - // DrawPortal is the exterior look-in (camera outside, peering in) → same outdoor gate: an - // interior cell with no real doorway aperture must not draw full-screen. (This path is dead - // after the cutover flip; kept compiling until the Step-D deletion.) - DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells, outdoorRoot: true); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, outdoorRoot: true); + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); RestoreNoClip(ctx.SetTerrainClipUbo); return result; @@ -191,19 +181,15 @@ public sealed class RetailPViewRenderer IRetailPViewCellDrawCallbacks ctx, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, - HashSet drawableCells, // param kept this task; removed in Task 4 - bool outdoorRoot) + HashSet drawableCells) // param kept this task; removed in Task 4 { // Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list - // (far→near), per portal_view slice. Indoors a cell without a clip-slot falls through - // GetCellSlicesOrNoClip to NoClipSlice and draws unclipped (sealed — load-bearing R1 seal). - // Outdoors (outdoor-node root) that same unclipped draw IS the see-through bug, so a cell with - // no real plane-clip aperture is skipped entirely (see DrawInside). + // (far→near), per portal_view slice. No drawableCells filter — a cell without a + // clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped + // (sealed; per-slice trim returns in Task 4). foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { uint cellId = entry.CellId; - if (outdoorRoot && !HasRealClipSlot(clipAssembly, cellId)) - continue; _oneCell.Clear(); _oneCell.Add(cellId); @@ -221,8 +207,7 @@ public sealed class RetailPViewRenderer PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, - InteriorEntityPartition.Result partition, - bool outdoorRoot) + InteriorEntityPartition.Result partition) { for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { @@ -230,11 +215,6 @@ public sealed class RetailPViewRenderer if (!drawableCells.Contains(cellId)) continue; - // Outdoor-node root: skip a cell with no real doorway aperture (would draw full-screen) — - // matches DrawEnvCellShells so a skipped interior cell shows neither walls nor furniture. - if (outdoorRoot && !HasRealClipSlot(clipAssembly, cellId)) - continue; - if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) continue; @@ -260,24 +240,6 @@ public sealed class RetailPViewRenderer return new[] { NoClipSlice }; } - /// - /// True iff has at least one view slice backed by a real plane-clip - /// slot (slot != 0) in this frame's assembly — i.e. a genuine doorway aperture. A cell with no - /// entry, or only the no-clip slot 0 (screen-covering / degenerate aperture → scissor fallback), - /// returns false. Used by the outdoor-node root to refuse drawing an interior cell that would - /// otherwise paint full-screen (the see-through-walls bug). Slot 0 is reserved as no-clip - /// (), so "real aperture" is precisely "some slot != 0". - /// - private static bool HasRealClipSlot(ClipFrameAssembly clipAssembly, uint cellId) - { - if (!clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)) - return false; - foreach (var slice in slices) - if (slice.Slot != 0) - return true; - return false; - } - private void UseIndoorMembershipOnlyRouting() { // Retail's PView portal views decide which cells/objects are eligible, From ef2186147d38d0da467bfd4cfa6e23df149aefc6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 21:49:00 +0200 Subject: [PATCH 040/172] =?UTF-8?q?docs:=20cutover=20flip=20shipped=20?= =?UTF-8?q?=E2=80=94=20see-through=20+=20oscillation=20DIAGNOSED=20(eviden?= =?UTF-8?q?ce-based=20handoff)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flip killed the branch-toggle flap (one path, zero OutdoorRoot frames). It exposed two residuals now PROVEN via a live [bshell] probe, not guessed: (1) oscillation = the outdoor-node flood membership swings 1<->~13 building cells frame-to-frame, so the walls (EnvCell shells) blink; (2) see-through = EnvCell wall polys are single-sided for SidesType==CounterClockwise, so from outside you see their culled back. The ModelId building shells DO render (6/6 with mesh) but are a partial frame, not the walls — the skip-all-interiors experiment proved the walls are the EnvCell shells. Fixes identified (stabilise flood + build back faces) but not implemented; full do-not-retry list + open pre-flip-reconciliation question in the doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...flip-render-residuals-diagnosis-handoff.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md diff --git a/docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md b/docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md new file mode 100644 index 00000000..4e806859 --- /dev/null +++ b/docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md @@ -0,0 +1,125 @@ +# Handoff — Cutover FLIP shipped; see-through + oscillation DIAGNOSED (evidence-based) — 2026-06-07 (PM) + +> **CANONICAL PICKUP for the render-unification residuals.** Worktree `thirsty-goldberg-51bb9b`, +> branch `claude/thirsty-goldberg-51bb9b`, HEAD `774cb22`. The cutover flip is SHIPPED (one render +> path, no branch-toggle flap). It exposed two residuals — **see-through building walls** and +> **oscillation** — whose root causes are now PROVEN with a live probe (not guessed). The fixes are +> identified but NOT yet implemented. Read §3 (diagnosis) and §5 (do-not-retry) before touching code. + +--- + +## 1. What shipped (committed, keep) + +The CUTOVER FLIP from `2026-06-07-render-unification-cutover-flip-handoff.md` landed: + +| Commit | What | +|---|---| +| `5379f6e` | Step A — `PortalVisibilityBuilder.Build` seeds a full-screen `OutsideView` when the root is the outdoor node (`LoadedCell.IsOutdoorNode`, set by `OutdoorCellNode.Build`). +2 UnifiedFloodTests, +2 flag assertions. | +| `445e861` | Step B — the flip: `GameWindow.cs:~7387` `clipRoot = viewerRoot ?? _outdoorNode`. Drops the `playerIndoorGate` gate. ONE path, no inside/outside branch. Preserves the `LiveDynamic` draw for the outdoor root. | +| `88caa0d` | depth-clear fix — `ClearDepthSlice = null` for the outdoor root (the full-screen depth clear was painting the cellar over the player; fixed). | +| `774cb22` | Revert of `0030dac` (the slot-0 skip — a FAILED fix, see §5). | + +**The flip's PRIMARY goal succeeded:** `[render-sig]` shows `branch=RetailPViewInside` every frame, +**zero `OutdoorRoot` frames** across a whole session. The two-branch-toggle flap is gone by +construction. Baselines: build green, App.Tests 216/0. + +--- + +## 2. The two residuals the flip exposed (user-observed) + +1. **See-through building walls from outside** — standing outside a building you see *into* it through + the walls (doors closed). +2. **Oscillation** — the interior/walls flicker between "showing nothing", "see-through", and "full + interior" frame-to-frame while standing still. + +--- + +## 3. ROOT CAUSE — proven by a live `[bshell]` probe (NOT guessed) + +A throttled probe in `RetailPViewRenderer.DrawInside` (now stripped; re-add from git history of this +doc's session if needed) logged, for the outdoor-node root on a loaded frame at the Holtburg cottage: + +``` +[bshell] total=6 withMesh=6 inOutdoorPartition=6 envCellsFlooded=1 outdoorEntities=637 +``` + +Interpretation (each number is decisive): + +- **`total=6 withMesh=6 inOutdoorPartition=6`** — there ARE 6 building `ModelId` "shell" entities + (`WorldEntity.IsBuildingShell`, created in `LandblockLoader.cs:75-91` from `LandBlockInfo.Buildings[].ModelId`), + ALL carry meshes, ALL land in `partition.Outdoor` (they have `ParentCellId==null`; + `InteriorEntityPartition` line 47 → Outdoor; `WbDrawDispatcher.EntityPassesVisibleCellGate` returns + `true` for null `visibleCellIds`). **So the `ModelId` exterior DOES render.** +- **BUT** the earlier "skip all interior shell draws for the outdoor root" experiment (uncommitted, + reverted) made the building **fully see-through** — i.e. drawing ONLY the `ModelId` shells is NOT a + solid building. **Therefore the `ModelId` Setup is a partial frame, and the building's actual WALLS + are the EnvCell shell geometry** (`ObjectMeshManager.PrepareCellStructMeshData`, drawn by + `DrawEnvCellShells`). +- **`envCellsFlooded=1`** — in this frame the outdoor-node flood reached **ZERO** building interior + cells (only the node itself). Earlier `[render-sig]` frames at the same spot showed `ids=[node + ~12 + building cells]` (≈13). **So the flood membership swings between 1 and ~13 frame-to-frame.** + +### The two residuals, explained +1. **Oscillation = flood instability gating the walls.** The flip made wall-drawing depend on the + portal flood reaching each building's interior cells. That flood is unstable (1 ↔ ~13), so the + EnvCell walls blink in and out. ("showing nothing" = flood=1, no interior; "full interior / + see-through" = flood reached the building.) +2. **See-through = single-sided EnvCell walls.** Even when the walls DO draw, the EnvCell wall polys + are single-sided for `SidesType==CounterClockwise` (interior-facing). `PrepareCellStructMeshData` + (ObjectMeshManager ~1299-1310) builds the back face only for `SidesType==None` (front twice + reversed) and `SidesType==Clockwise` (neg surface). A `CounterClockwise` wall = front face only → + from outside you see its culled back → see-through. + +--- + +## 4. Fix path (identified, NOT implemented) + +Two independent fixes, both needed: + +- **F1 — Stabilise the flood membership** so a building's interior cells are CONSISTENTLY in/out of + the visible set (no 1↔13 swing). This is the same metastability family as the indoor flicker. Likely + levers: ground the outdoor-node flood's building membership in the cell `stab_list`/PVS (stable, + precomputed) instead of the per-frame portal-side test + projection; or hysteresis on which buildings + are flooded. Probe to re-add: `envCellsFlooded` per frame (RLE it; it should be constant when standing + still). +- **F2 — Make the EnvCell walls solid from outside.** Either build the missing back faces for + `SidesType==CounterClockwise` walls in `PrepareCellStructMeshData`, or render those shells + double-sided (`CullMode.None`) when the viewer is outside the cell. Verify against retail: dump a real + Holtburg cell's wall-poly `SidesType` distribution first. + +**Open research question (reconcile before F2):** pre-flip the buildings looked SOLID from outside. +What drew the solid walls pre-flip — a global EnvCell-shell render, the `DrawPortal`/`BuildFromExterior` +look-in, or were the `ModelId` shells solid then? Find what the flip replaced. The old outdoor `else` +block (`GameWindow.cs:~7557-7663`, now dead-when-clipRoot-non-null but still present) is the place to +read. This answers whether F2 is "build back faces" or "restore a pre-flip draw". + +--- + +## 5. DO NOT RETRY (failed this session, with evidence) + +- **Slot-0 skip** (`0030dac`, reverted `774cb22`): "for the outdoor root, skip flooded cells whose + clip degenerated to no-clip slot 0." Made the oscillation WORSE — slot-0-ness flickers per frame, so + cells blinked. Wrong: the see-through is not the slot-0 fallback. +- **Skip-all-interiors experiment** (uncommitted, reverted): "outdoor root draws terrain + ModelId + exteriors only, no EnvCell shells." Made buildings FULLY see-through + flashing — proved the `ModelId` + Setup is not the walls (the walls are the EnvCell shells). Do not ship this. +- **Backface-culling-of-shells hypothesis** (never coded): plausible but the cull mode is already + data-driven (`poly.SidesType`); the real gap is single-sided geometry (no back face built), not a + cull-state bug. +- The subagent hypothesis "ModelId exterior occludes; interior overdraws it; fix = gate DrawEnvCellShells + off for the outdoor root" is **disproven** — that gate IS the skip-all-interiors experiment, which + removed the walls entirely. + +--- + +## 6. State + how to resume +- HEAD `774cb22`, tree clean, build green, App.Tests 216/0. The flip + depth-clear are committed; the + branch renders with the two residuals (see-through + oscillation). +- The flip is on this BRANCH only (main is unaffected). To get a stable client meanwhile, revert the + flip commits (`445e861` Step B is the behaviour change; reverting it alone restores the pre-flip + outdoor path — verify Step A `5379f6e` is inert without it). +- Re-add the `[bshell]` / `envCellsFlooded` probe (see this session's git reflog for the exact code) to + watch flood stability while working F1. +- Memory: `project_indoor_flap_rootcause` (update with this corrected diagnosis), + `reference_render_pipeline_state`, `feedback_render_downstream_of_membership` (the oscillation IS a + membership/flood-stability bug, per that note). From 5c6e53b0a4168cda482b01e5b3df46401b6e1be6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 7 Jun 2026 22:12:38 +0200 Subject: [PATCH 041/172] docs: add verify-first kickoff prompt to the render-residuals handoff (treat the diagnosis as a suspect statement) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...flip-render-residuals-diagnosis-handoff.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md b/docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md index 4e806859..1f2bb6af 100644 --- a/docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md +++ b/docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md @@ -123,3 +123,76 @@ read. This answers whether F2 is "build back faces" or "restore a pre-flip draw" - Memory: `project_indoor_flap_rootcause` (update with this corrected diagnosis), `reference_render_pipeline_state`, `feedback_render_downstream_of_membership` (the oscillation IS a membership/flood-stability bug, per that note). + +--- + +## 7. Next-session kickoff prompt — VERIFY-FIRST (the diagnosis above is a SUSPECT'S statement) + +This session reached the §3 diagnosis only AFTER three wrong guesses, so the next session must verify it +cold before building any fix. Paste this to start: + +``` +Continue acdream M1.5 render unification. Branch claude/thirsty-goldberg-51bb9b, HEAD 774cb22. +PowerShell on Windows; build before launch; live ACE 127.0.0.1:9000, testaccount/testpassword, char ++Acdream (spawns at the Holtburg/Arcanum cottage, landblock 0xA9B4). + +The render-unification CUTOVER FLIP is committed. It is CLAIMED to have killed the two-branch render +"flap" but left two residuals — see-through building walls and oscillation — and a root-cause diagnosis +was reached. That diagnosis was only reached AFTER THREE WRONG GUESSES in the same session, so DO NOT +trust it. Your FIRST job is to verify it cold, with fresh primary evidence, and you are explicitly +licensed to REFUTE it. + +READ (as a suspect's statement, NOT as truth): +- docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md (claimed diagnosis + + do-not-retry list + the probe numbers it rests on) +- memory project_indoor_flap_rootcause (corrected diagnosis claim) + +=== TASK 1 — UNBIASED VERIFICATION (complete fully BEFORE proposing any fix) === +Do not anchor on the handoff's conclusions — re-derive each from independent evidence. For each claim, +report CONFIRMED / REFUTED / CORRECTED with the evidence. If ANY load-bearing claim is refuted, the +diagnosis is wrong: STOP, re-diagnose, do not build a fix on it. Prefer dispatching the verification to a +fresh subagent that has NOT seen the conclusions, to avoid confirmation bias. + +1.1 "The branch-toggle flap is gone (one render path)." Launch (ACDREAM_PROBE_FLAP=1); walk + indoor<->outdoor and pan the camera at a doorway; RLE the [render-sig] `branch` field. Expected if + true: zero `branch=OutdoorRoot` after spawn. Refute if OutdoorRoot reappears. + +1.2 "Oscillation == outdoor-node flood membership instability." Add a probe logging + pvFrame.OrderedVisibleCells.Count per outdoor-root frame WHILE STANDING PERFECTLY STILL at the + cottage. Swings frame-to-frame (e.g. 1<->13) -> unstable (confirms). Constant while the user still + sees oscillation -> DIFFERENT cause (refute + re-diagnose). Correlate the swings with what visibly + flickers. + +1.3 "See-through == single-sided EnvCell walls." Dump the actual sides_type distribution of a REAL + Holtburg building cell's wall polygons (Environment dat -> CellStruct, focused test/tool over the + cottage cell). Confirm walls are predominantly single-sided AND that PrepareCellStructMeshData + (ObjectMeshManager ~1299-1310) builds a back face only for SidesType None/Clockwise (not + CounterClockwise). FALSIFIABLE cross-check: temporarily force the EnvCell shell pass to CullMode.None + (double-sided) and confirm THAT alone makes the walls solid from outside; revert after. + +1.4 "The building WALLS are the EnvCell shells; the ModelId 'shell' is only a partial frame." Re-add the + [bshell] probe (total/withMesh/inOutdoorPartition/envCellsFlooded). Independently inspect what the + building ModelId Setup geometry IS (poly count, bbox) vs the EnvCell shell. Reproduce or refute the + skip-all-interiors experiment (building went fully see-through). + +1.5 (OPEN, decides the fix shape) "Pre-flip buildings were solid from outside — what drew the walls?" + Check out the pre-flip commit (parent of 445e861), launch, confirm buildings solid from outside, + trace what drew the solid walls (old outdoor `else` block GameWindow.cs ~7557-7663 / + DrawPortal+BuildFromExterior / a global EnvCell-shell render). Decides whether F2 is "build missing + back faces" or "restore a pre-flip draw the flip replaced". + +DO-NOT-RETRY (proven failures last session): the slot-0 skip (made oscillation worse); skipping all +interior shells / gating DrawEnvCellShells off for the outdoor root (building fully see-through — already +ran); any render-side debounce/grace (forbidden, no-workarounds). + +=== TASK 2 — only AFTER Task 1 confirms or corrects the diagnosis === +Implement F1 (stabilise flood membership — e.g. ground building membership in the cell stab_list/PVS +instead of the per-frame portal-side test) and F2 (the verified wall-sidedness fix). TDD where possible; +each lands behind a USER VISUAL GATE at the cottage. Do not delete the dead DrawPortal/BuildFromExterior/ +outdoor-else paths until the residuals are visually confirmed fixed. +``` + +**Why verify-first:** the fastest single decisive test is the §1.3 falsifiable cross-check (force +`CullMode.None`; if walls go solid from outside, the single-sided-wall hypothesis is confirmed and F2 is +"build back faces"). Run the verification under a fresh subagent so it can't rubber-stamp these +conclusions. From d9d69394bbd4588e49baee03a9a3763ae18297af Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 08:23:34 +0200 Subject: [PATCH 042/172] =?UTF-8?q?docs(render):=20spec=20=E2=80=94=20port?= =?UTF-8?q?al-flood=20membership=20stability=20(indoor=20flap=20root-cause?= =?UTF-8?q?=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed root cause via primary evidence (determinism test + 6dp jitter probe + retail grounding): the flap is portal-flood set-membership flipping because the drift-prone ClipToRegion vertex count gates membership while the player RenderPosition micro-jitters (~1-8um) into a grazing portal's knife-edge clip. Design: gate membership on a stable side-test + view-region overlap, not the vertex count. Refutes the 2026-06-07 see-through/ EnvCell/outdoor-node handoff (ModelId GfxObj 0x01000A2B is the solid exterior; outside is stable; root is stable 0170). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ortal-flood-membership-stability-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md diff --git a/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md new file mode 100644 index 00000000..90bb5b98 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md @@ -0,0 +1,213 @@ +# Portal-Flood Membership Stability — the indoor "flap" root-cause fix + +**Date:** 2026-06-08 +**Branch:** `claude/thirsty-goldberg-51bb9b` +**Status:** design approved (user, 2026-06-08); TDD implementation pending behind a visual gate. + +--- + +## 1. Summary + +The indoor render **flap** (textures "battling" at the doorway threshold) is **portal-flood +set-membership instability**: from a *stable* viewer cell, the PView BFS includes or excludes a +deeper cell cluster frame-to-frame, redrawing a different set each frame. The fix makes set +membership depend on a **stable visibility predicate** (side-test + view-region overlap) instead of +the **drift-prone surviving-vertex count** of the per-portal clip. Localized to +`PortalVisibilityBuilder`; no camera/movement/physics/clip-math rewrite. + +--- + +## 2. Root cause — confirmed with primary evidence + +### 2.1 What the flap actually is + +Live `[render-sig]` + `[pv-input]` capture at the Holtburg cottage threshold (landblock `0xA9B4`), +standing at the doorway: + +- The render root is **stable** (`root=0xA9B40170`, `outRoot=n`, i.e. an interior viewer cell — NOT + the outdoor node, NOT a root toggle). +- The flood cell set **oscillates frame-to-frame**: `ids=[0170,0171,0172,0173,0174,0175]` (6) ↔ + `ids=[0170,0171]` (2). The deeper cluster `{0172,0173,0174,0175}` pops in/out. +- The oscillation occurs **at a byte-identical (to cm) eye AND player position** — e.g. three + consecutive frames at eye `(155.55,15.45,96.05)`, player `(155.40,13.20,94.00)` with flood + `6,2,6`. + +### 2.2 Why it flips — the mechanism + +1. `PortalVisibilityBuilder.Build` is a **pure** static function with all-fresh per-call state + (new `frame`/`todo`/`queued`/`popCounts` every call). Proven deterministic by + `PortalVisibilityBuilderTests.Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet` + (passes). **So for identical inputs the output cannot flip** → the flip requires a varying input. +2. The high-precision `[pv-input]` probe (6 dp) shows the camera eye and the **player + `RenderPosition` carry perpetual ~1–8 µm float jitter every frame** even "standing still" + (e.g. player `94.000000 ↔ 94.000008`, eye `96.248863 ↔ 96.248871`). At most poses this is + harmless; the flood is stable. +3. The per-portal clip is a faithful homogeneous port of retail's `polyClipFinish` + (`PortalProjection.ProjectToClip` → `ClipToRegion`, w-aware Sutherland–Hodgman). But the + **re-enqueue fixpoint** (`MaxReprocessPerCell`) re-clips a cell's view each round, and the + codebase documents that this **drifts per round** (`PortalVisibilityBuilder.cs:43,151,732`: + "ProjectToClip drift keeps a view growing forever"). +4. At the threshold pose a deeper portal is **grazing** (oblique / near the eye) → it projects to a + thin sliver. The per-round drift + the µm viewpoint jitter flip `ClipToRegion`'s surviving-vertex + count across the `<3` boundary (PortalProjection.cs:118/121) → `clippedRegion.Count` flips + `0 ↔ N` → the cull at **`PortalVisibilityBuilder.cs:235`** + (`if (clippedRegion.Count == 0 && !EyeInsidePortalOpening) continue;`) drops the deeper cluster + on the empty-clip frames → flood `2 ↔ 6` → the flap. + +### 2.3 Why prior fixes did not work + +- **boom-snap** (camera stabilization, shipped): the jitter is sub-cm and **perpetual** (it is in the + player `RenderPosition`, propagating to the camera); snapping the boom distance did not make the + viewpoint bit-exact, so the knife-edge still flips. +- **w-space clip** (`ProjectToClip`/`ClipToRegion`, shipped): this made the *single* clip robust, but + the instability is in the **re-clip drift across rounds** + the membership gate's dependence on the + surviving-vertex count, not in a single clip. +- **viewer-cell dead-zone** (tried, reverted): the root does not toggle here (`root=0170` stable), so + a root-resolution dead-zone is irrelevant to this symptom. + +### 2.4 What this REFUTES (the 2026-06-07 handoff diagnosis) + +The predecessor handoff +(`docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md`) is **wrong** on its +load-bearing claims; do not act on its F1/F2: + +- "See-through walls from outside" — **not reproduced**: standing outside with the door closed is + **stable** (user visual gate, 2026-06-08). +- "The walls ARE the EnvCell shells; the ModelId is a partial frame" — **refuted**: the cottage + ModelId GfxObj `0x01000A2B` is a full closed exterior (76 render polys, bbox 20×18×10.4 m, 46 + outward-facing walls + roof; cross-checked vs the physics BSP + retail `DrawBuilding`). The EnvCell + shells are interior-facing room surfaces. **F2 (build EnvCell back faces / double-side) targets the + wrong geometry.** +- "Oscillation = outdoor-node flood instability (1↔13)" — **corrected**: it is the *indoor* flood + (`outRoot=n`, stable root) swinging **2↔6**. F1 targeted the wrong root. +- "branch=RetailPViewInside every frame proves the flap is gone" — **tautological**: post-flip + `clipRoot = viewerRoot ?? _outdoorNode` is essentially never null, so the `branch` label can no + longer report `OutdoorRoot`. It proves nothing. + +--- + +## 3. Retail grounding + +Retail `PView::ConstructView` (decomp `acclient_2013_pseudo_c.txt:433750`): a cell becomes a draw-set +member the moment it is popped from the todo list (`:433783`). A neighbour is enqueued only if the +per-portal `ConstructView` (`:433827`) passes: the **side-test** (`:433832-433849`, `dot(viewpoint, +planeN)+d` vs a 0.2 mm epsilon → POSITIVE/IN_PLANE/NEGATIVE) **AND** `GetClip` (`:432344`) returns a +**non-empty** clip (`:433858` `if (arg3 != 0)`). `GetClip` projects via `xformStart` and clips via +`ACRender::polyClipFinish` (`:702749`). + +So retail gates membership on a non-empty clip **too** — it never flaps because (a) it processes each +cell **once** (enqueue-once; no re-clip drift) and (b) its viewpoint is **bit-stable at rest** (the +authoritative local position does not move). acdream diverges on **both** (re-enqueue drift + µm +viewpoint jitter), and the two combine at the grazing portal. + +The fix restores retail's **intent** — "the portal is visible through the accumulated view" — with a +predicate that is stable under acdream's residual drift/jitter, rather than the literal +drift-sensitive vertex count. + +--- + +## 4. The fix (design) + +**Principle:** set-membership is decided by a **stable** visibility predicate, not by the drift-prone +surviving-vertex count of the clip. The clip still computes the *draw* region; it no longer decides +*whether* a reachable cell is in the set. + +**Change — localized to `PortalVisibilityBuilder` (the line-235 gate):** + +- Today (`PortalVisibilityBuilder.cs:235-244`): + ``` + if (clippedRegion.Count == 0) + { + if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) continue; // cull + foreach (var vp in activeViewPolygons) clippedRegion.Add(clone(vp)); // flood with parent view + } + ``` +- New: when `clippedRegion.Count == 0` but the portal **passed the side-test** (already computed: + `sideAllowed`, the stable plane-side test) **and its projection still overlaps the current view + region** (a stable convex-overlap predicate — true for a thin grazing sliver inside the region, + false for an off-screen portal), keep the neighbour by flooding it with the parent's view (the same + substitution the `EyeInsidePortalOpening` branch already does). Otherwise cull as today. + +The drift-prone `clippedRegion.Count` no longer flips membership; a portal that is genuinely visible +through the accumulated view (stable side-test + stable overlap) stays in the set every frame. + +**The stable overlap predicate** (`PortalOverlapsView`, new small helper): does the portal's +projected polygon overlap any of the `activeViewPolygons`? Implemented to be stable for the +near/grazing case (the failure mode is `ClipToRegion` losing a vertex to float noise, NOT the gross +position of the sliver, which sits well inside the view region — so a robust "any-overlap" test +returns a steady boolean). Exact formulation is fixed in TDD (§5); candidates: (a) any portal NDC +vertex inside the region OR any region vertex inside the portal OR any edge crossing; (b) reuse the +existing `EyeInsidePortalOpening` 3D near-region test generalized from "eye in opening" to "eye within +the portal's view cone." The chosen formulation MUST keep the #95 guard test green. + +**This subsumes the `EyeInsidePortalOpening` special-case** (a portal the eye stands in trivially +overlaps the full-screen region), so that ad-hoc patch is removed once the general predicate is in +place — fewer special cases, not more. + +**#95 over-inclusion guard preserved:** an off-screen portal (2 m to the side) does not overlap the +view region → still culled. No visible-set blowup. + +--- + +## 5. Verification (TDD) + +Write the failing test first, then the fix. + +1. **RED → GREEN — degenerate-clip membership.** New deterministic test in + `PortalVisibilityBuilderTests`: construct an interior portal that (a) passes the side-test, (b) + whose projection overlaps the view region, but (c) whose `ClipToRegion` returns `<3` verts + (degenerate sliver — the live failure mode), and the eye is NOT standing in the opening. Assert the + neighbour **is** in `OrderedVisibleCells`. RED today (culled at line 235 because not + `EyeInsidePortalOpening`); GREEN after the fix (kept because side-test + overlap). This pins the + gate change without needing to reproduce the exact µm knife-edge. + - *Optional companion (robustness):* if a fixture can be found whose clip flips `<3 ↔ ≥3` under a + µm eye nudge, add a test asserting `OrderedVisibleCells` is identical across the nudge. Skip if + it proves too geometry-sensitive to construct stably — the deterministic test above is the gate. +2. **Stays GREEN — #95 over-inclusion guard.** `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` + (off-screen portal stays culled). +3. **Stays GREEN — existing behavior.** `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, + `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`, + `Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet`, and the existing cellar/window + clip tests. +4. **Visual gate (user).** At the cottage doorway threshold, hold still — the 2↔6 oscillation is + gone; the deeper rooms render steadily through the door. Walking in/out remains seamless. + +`dotnet build` + `dotnet test` green before the visual gate. + +--- + +## 6. Scope / non-goals + +- **In scope:** `PortalVisibilityBuilder` (the line-235 gate + the `PortalOverlapsView` helper), + removal of the now-subsumed `EyeInsidePortalOpening` force-flood branch, the new + existing tests. +- **Non-goals (explicitly deferred):** + - No camera / movement / interpolation / physics changes (the µm viewpoint jitter is left as-is; + the fix is robust to it). + - No clip-math rewrite (`ProjectToClip`/`ClipToRegion` stay). + - **Restoring retail's enqueue-once traversal** (removing the re-enqueue fixpoint, eliminating the + per-round drift at its source) is a real, larger, retail-faithful improvement but a **separate + step** — out of scope here. This fix neutralizes the drift's effect on membership without + restructuring the BFS. + +--- + +## 7. Apparatus (diagnostic probes added this session) + +- **Keep:** `PortalVisibilityBuilderTests.Build_IsDeterministic_*` (regression value); + `tools/A8CellAudit` `gfxobj` dump mode (reusable). +- **Strip after the fix is visually verified:** the `[pv-input]` probe + `RenderingDiagnostics.ProbePvInputEnabled` + (GameWindow.cs / RenderingDiagnostics.cs), the `outRoot=`/`bshell=` fields added to `[render-sig]`, + and `launch-bshell-probe.ps1` / `launch-pvinput.ps1`. All env-var-gated and inert when off; safe to + leave until the visual gate passes, then remove. + +--- + +## 8. References + +- Diagnosis evidence + refutation: this session's `[render-sig]`/`[pv-input]` captures (cottage + threshold), the `Build_IsDeterministic` test, the GfxObj `0x01000A2B` render-geometry dump. +- Retail decomp: `PView::ConstructView` `:433750`/`:433827`, `PView::GetClip` `:432344`, + `ACRender::polyClipFinish` `:702749` (`docs/research/named-retail/acclient_2013_pseudo_c.txt`). +- Superseded: `docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md` (wrong on + see-through / EnvCell-walls / outdoor-node — see §2.4). +- Memory to correct: `project_indoor_flap_rootcause`, `reference_render_pipeline_state`. From d0b65c41704c42c8c84331ecbe54d201b52adb1a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 08:51:51 +0200 Subject: [PATCH 043/172] docs(render): re-scope flap fix to retail enqueue-once traversal port (not an overlap band-aid) Per senior-eng direction: the retail-faithful fix is to stop diverging from PView:: AddViewToPortals (first-discovery enqueue + AddToCell/FixCellList in-place growth, no re-enqueue/re-clip), removing acdream's MaxReprocessPerCell re-enqueue fixpoint and its documented per-round ProjectToClip drift. Drops the overlap-predicate approach. Viewpoint bit-stability (the ~1-8um player RenderPosition jitter) is the contingency next step only if a residual flap survives the visual gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ortal-flood-membership-stability-design.md | 145 ++++++++++-------- 1 file changed, 79 insertions(+), 66 deletions(-) diff --git a/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md index 90bb5b98..08621cc2 100644 --- a/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md +++ b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md @@ -10,10 +10,16 @@ The indoor render **flap** (textures "battling" at the doorway threshold) is **portal-flood set-membership instability**: from a *stable* viewer cell, the PView BFS includes or excludes a -deeper cell cluster frame-to-frame, redrawing a different set each frame. The fix makes set -membership depend on a **stable visibility predicate** (side-test + view-region overlap) instead of -the **drift-prone surviving-vertex count** of the per-portal clip. Localized to -`PortalVisibilityBuilder`; no camera/movement/physics/clip-math rewrite. +deeper cell cluster frame-to-frame, redrawing a different set each frame. The fix is a **verbatim +port of retail's enqueue-once traversal** (`PView::ConstructView`/`AddViewToPortals`): a cell is +enqueued **only on first discovery**; later view-growth into an already-discovered cell is unioned +**in place** (retail `AddToCell`/`FixCellList`) and **never re-enqueues or re-clips** that cell's +portals. This removes acdream's `MaxReprocessPerCell` **re-enqueue fixpoint** — the documented +per-round `ProjectToClip` **drift** that lets µm viewpoint jitter re-discover/undiscover the deep +cluster. Localized to `PortalVisibilityBuilder`; no overlap-predicate, no added robustness, no +camera/movement/physics/clip-math change. (Contingency: if a residual flap survives — the deep +portal's *first* clip being knife-edge under µm jitter independent of drift — the next +retail-faithful step is bit-stabilizing the viewpoint at rest; see §6.) --- @@ -100,77 +106,79 @@ cell **once** (enqueue-once; no re-clip drift) and (b) its viewpoint is **bit-st authoritative local position does not move). acdream diverges on **both** (re-enqueue drift + µm viewpoint jitter), and the two combine at the grazing portal. -The fix restores retail's **intent** — "the portal is visible through the accumulated view" — with a -predicate that is stable under acdream's residual drift/jitter, rather than the literal -drift-sensitive vertex count. +The fix restores retail's traversal **verbatim** — enqueue-once on first discovery, union-in-place on +growth — so acdream stops diverging from `AddViewToPortals` and the per-round re-clip drift disappears. +No new predicate, no added robustness. --- ## 4. The fix (design) -**Principle:** set-membership is decided by a **stable** visibility predicate, not by the drift-prone -surviving-vertex count of the clip. The clip still computes the *draw* region; it no longer decides -*whether* a reachable cell is in the set. +**Principle:** membership is set by **first discovery** in distance-priority order (retail +`InsCellTodoList` in the `AddViewToPortals` `update_count == 0` branch, decomp `:433478`). A cell +already discovered is **never re-enqueued and never re-clipped**; later view-growth into it is unioned +**in place** and only refines that cell's own draw clip / draw-list position (retail `AddToCell` + +`FixCellList`, `:433492-433502`). The drift-prone re-clip loop is deleted, so µm viewpoint jitter can +no longer re-discover/undiscover a cell. -**Change — localized to `PortalVisibilityBuilder` (the line-235 gate):** +**Change A — enqueue-once (the core fix), `PortalVisibilityBuilder.cs` ~308-327.** +Today a neighbour is RE-enqueued whenever its view `grew`, capped by `MaxReprocessPerCell`: -- Today (`PortalVisibilityBuilder.cs:235-244`): - ``` - if (clippedRegion.Count == 0) - { - if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) continue; // cull - foreach (var vp in activeViewPolygons) clippedRegion.Add(clone(vp)); // flood with parent view - } - ``` -- New: when `clippedRegion.Count == 0` but the portal **passed the side-test** (already computed: - `sideAllowed`, the stable plane-side test) **and its projection still overlaps the current view - region** (a stable convex-overlap predicate — true for a thin grazing sliver inside the region, - false for an off-screen portal), keep the neighbour by flooding it with the parent's view (the same - substitution the `EyeInsidePortalOpening` branch already does). Otherwise cull as today. + bool grew = AddRegion(nview, clippedRegion); // union in place (= retail AddToCell) + if (grew && popCounts[neighbourId] < MaxReprocessPerCell // RE-ENQUEUE on growth ← the divergence + && queued.Add(neighbourId)) + todo.Insert(neighbour, dist); -The drift-prone `clippedRegion.Count` no longer flips membership; a portal that is genuinely visible -through the accumulated view (stable side-test + stable overlap) stays in the set every frame. +New: enqueue a neighbour **only on first discovery** (no `CellViews` / `processedViewCounts` entry +yet). On growth into an already-discovered neighbour, union in place (keep `AddRegion`) and update its +draw-list position if already drawn (port `FixCellList`), but **do not** re-insert it into the todo +list. Remove `MaxReprocessPerCell`, `popCounts`, and the per-pop cap — enqueue-once terminates by +construction (≤ N cells), matching retail's `cell_view_done` guarantee (`:433784`). -**The stable overlap predicate** (`PortalOverlapsView`, new small helper): does the portal's -projected polygon overlap any of the `activeViewPolygons`? Implemented to be stable for the -near/grazing case (the failure mode is `ClipToRegion` losing a vertex to float noise, NOT the gross -position of the sliver, which sits well inside the view region — so a robust "any-overlap" test -returns a steady boolean). Exact formulation is fixed in TDD (§5); candidates: (a) any portal NDC -vertex inside the region OR any region vertex inside the portal OR any edge crossing; (b) reuse the -existing `EyeInsidePortalOpening` 3D near-region test generalized from "eye in opening" to "eye within -the portal's view cone." The chosen formulation MUST keep the #95 guard test green. +**Change B — exit-portal / `OutsideView` contribution stays first-process.** Retail contributes a +cell's exit-portal slice to `OutsideView` once, when the cell is processed; there is no re-enqueue +path in `AddViewToPortals` to re-contribute a grown view. acdream's `OutsideView` contribution +(line 256) already happens at process time, so removing the re-enqueue makes it match retail. +**Regression watch:** the re-enqueue was added 2026-06-07 "to propagate late-discovered slices to exit +portals" — which retail does **not** do, so dropping it is faithful, but a look-in / outside-view +slice could shrink. The existing OutsideView tests (`Builder_Cellar_WindowClippedToStairwell`, the +look-in tests) must stay green; if one shrinks, the fix is retail's `AddToCell`/`FixCellList` ordering, +**not** reinstating the re-enqueue. -**This subsumes the `EyeInsidePortalOpening` special-case** (a portal the eye stands in trivially -overlaps the full-screen region), so that ad-hoc patch is removed once the general predicate is in -place — fewer special cases, not more. +**`EyeInsidePortalOpening` (line 235-244) is unchanged by this fix.** It is a separate near-degenerate +single-clip guard (eye standing in a doorway), orthogonal to the re-enqueue, and stays as-is. **No +overlap predicate is introduced.** -**#95 over-inclusion guard preserved:** an off-screen portal (2 m to the side) does not overlap the -view region → still culled. No visible-set blowup. +**Why this is the flap fix, not a band-aid:** the re-enqueue re-clips a popped cell's portals from its +*grown* (drifted) view and can therefore **add or drop** the deep `0172-0175` cluster as the drift +walks across the clip boundary under µm jitter. Enqueue-once decides the cluster's membership **once**, +at first discovery, from the cell's clean first-accumulated view — the same decision retail makes. --- ## 5. Verification (TDD) -Write the failing test first, then the fix. +The flap itself is float-drift-dependent (it manifests only under live µm jitter at a specific grazing +geometry), so the **visual gate is the acceptance**; the unit layer pins enqueue-once correctness and +guards regressions. -1. **RED → GREEN — degenerate-clip membership.** New deterministic test in - `PortalVisibilityBuilderTests`: construct an interior portal that (a) passes the side-test, (b) - whose projection overlaps the view region, but (c) whose `ClipToRegion` returns `<3` verts - (degenerate sliver — the live failure mode), and the eye is NOT standing in the opening. Assert the - neighbour **is** in `OrderedVisibleCells`. RED today (culled at line 235 because not - `EyeInsidePortalOpening`); GREEN after the fix (kept because side-test + overlap). This pins the - gate change without needing to reproduce the exact µm knife-edge. - - *Optional companion (robustness):* if a fixture can be found whose clip flips `<3 ↔ ≥3` under a - µm eye nudge, add a test asserting `OrderedVisibleCells` is identical across the nudge. Skip if - it proves too geometry-sensitive to construct stably — the deterministic test above is the gate. -2. **Stays GREEN — #95 over-inclusion guard.** `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` - (off-screen portal stays culled). -3. **Stays GREEN — existing behavior.** `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, +1. **Enqueue-once correctness + termination (new).** A multi-path fixture in + `PortalVisibilityBuilderTests`: a **diamond** (a cell reachable from two parents, so its view grows + after first discovery) and a **cycle** (portals looping back). Assert the flood (a) **terminates + with `MaxReprocessPerCell` removed**, (b) yields a **deduped** `OrderedVisibleCells`, and (c) each + reachable cell is present exactly once. This is the property the re-enqueue cap was protecting; + enqueue-once provides it by construction. If a per-cell pop counter is cheap to surface, also assert + **each cell is popped ≤ 1** (RED under the re-enqueue, GREEN after) — the direct enqueue-once signal. +2. **No membership regression on known geometries.** `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`, - `Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet`, and the existing cellar/window - clip tests. -4. **Visual gate (user).** At the cottage doorway threshold, hold still — the 2↔6 oscillation is - gone; the deeper rooms render steadily through the door. Walking in/out remains seamless. + `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95 guard), `Build_IsDeterministic_*`, + and the cellar/window/look-in tests stay **green** (re-enqueue and enqueue-once agree on + non-drifting geometry; if one changes, that is the §4 Change-B regression to handle retail's way, + NOT by reinstating the re-enqueue). +3. **Visual gate (user) — the acceptance.** At the cottage doorway threshold, hold still: the 2↔6 + oscillation is gone; the deeper rooms render steadily through the door; walking in/out stays + seamless. Re-run the `[pv-input]`/`[render-sig]` probes to confirm `ids=`/flood is stable while + standing still. `dotnet build` + `dotnet test` green before the visual gate. @@ -178,16 +186,21 @@ Write the failing test first, then the fix. ## 6. Scope / non-goals -- **In scope:** `PortalVisibilityBuilder` (the line-235 gate + the `PortalOverlapsView` helper), - removal of the now-subsumed `EyeInsidePortalOpening` force-flood branch, the new + existing tests. +- **In scope:** `PortalVisibilityBuilder` enqueue logic — enqueue-once on first discovery; remove the + `MaxReprocessPerCell` re-enqueue, `popCounts`, and the per-pop cap; union-in-place + draw-list + re-position on growth (port retail `AddToCell`/`FixCellList`); the new + existing tests. - **Non-goals (explicitly deferred):** - - No camera / movement / interpolation / physics changes (the µm viewpoint jitter is left as-is; - the fix is robust to it). - - No clip-math rewrite (`ProjectToClip`/`ClipToRegion` stay). - - **Restoring retail's enqueue-once traversal** (removing the re-enqueue fixpoint, eliminating the - per-round drift at its source) is a real, larger, retail-faithful improvement but a **separate - step** — out of scope here. This fix neutralizes the drift's effect on membership without - restructuring the BFS. + - **No overlap predicate / no added robustness** — this is a verbatim retail port, not a new + membership rule. `EyeInsidePortalOpening` (line 235) is untouched. + - **No clip-math rewrite** (`ProjectToClip`/`ClipToRegion` stay). + - **No camera / movement / interpolation / physics changes** in this step. +- **Contingency (next retail-faithful step, only if a residual flap survives the visual gate):** + bit-stabilize the viewpoint at rest. The live `[pv-input]` probe shows the player `RenderPosition` + carries ~1–8 µm float noise at rest (e.g. Z `94.000000 ↔ 94.000008`), which retail's authoritative + local position does not. If enqueue-once leaves a residual flicker (the deep portal's *first* clip is + knife-edge under that jitter), trace the jitter to its source (interpolation residual vs physics + contact-settling) and make the local-player viewpoint bit-stable at rest, matching retail. Scoped as + a separate step because it touches the movement/physics path; do it only if measured necessary. --- From d6aa526dd3459c6159dca448cfe1a36256fb9568 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 09:16:12 +0200 Subject: [PATCH 044/172] =?UTF-8?q?diag(render/physics):=20flap=20root-cau?= =?UTF-8?q?sed=20to=20physics=20rest=20=C2=B5m-jitter;=20refute=20prior=20?= =?UTF-8?q?diagnoses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apparatus + handoff for the indoor flap. Confirmed (primary evidence): the flap is the portal-flood clip being µm-sensitive at the threshold, driven by a ~1-8µm jitter in the player RenderPosition (physics resting position not bit-stable; Lerp surfaces it). REFUTES the 2026-06-07 see-through/EnvCell/outdoor-node diagnosis (ModelId GfxObj 0x01000A2B IS the solid exterior) AND an enqueue-once attempt (retail propagates late slices via AddToCell; the existing PropagatesNewSlicesToExit test caught it; reverted). Adds: Build determinism test, A8CellAudit gfxobj dump, [pv-input] 6dp probe + [render-sig] outRoot/bshell fields. No functional fix shipped. Next: higher-precision physics rest trace -> port retail kill_velocity/contact rest-stability. Canonical: docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-08-flap-rootcause-physics-rest-handoff.md | 119 ++++++++++++++++++ ...ortal-flood-membership-stability-design.md | 7 +- src/AcDream.App/Rendering/GameWindow.cs | 29 +++++ .../Rendering/RenderingDiagnostics.cs | 14 +++ .../Rendering/PortalVisibilityBuilderTests.cs | 32 +++++ tools/A8CellAudit/Program.cs | 100 +++++++++++++++ 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md diff --git a/docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md b/docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md new file mode 100644 index 00000000..eb872196 --- /dev/null +++ b/docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md @@ -0,0 +1,119 @@ +# Handoff — the indoor FLAP traced to a physics rest µm-jitter; prior diagnoses REFUTED — 2026-06-08 + +> **CANONICAL PICKUP for the indoor render flap.** This session refuted the 2026-06-07 cutover-flip +> diagnosis AND an enqueue-once attempt, confirmed the real mechanism with primary evidence, and traced +> the root all the way down to a **physics resting-position µm jitter**. The fix is in physics +> (rest-stability), is teed up, and needs one more **higher-precision** trace to pin the exact cause +> before porting. Spec: `docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md` +> (its §4 enqueue-once design is REFUTED — see §3 here; its §6 physics contingency is now the active +> direction). + +--- + +## 1. What the flap IS (confirmed, primary evidence) + +At the Holtburg cottage **doorway threshold**, the portal-visibility flood set oscillates frame-to-frame +(`ids=[0170,0171,0172,0173,0174,0175]` ↔ `[0170,0171]`, i.e. 6↔2 cells) **from a stable viewer cell** +(`root=0xA9B40170`, `outRoot=n`). The deep `0172-0175` cluster pops in/out → textures "battle." + +- It is **NOT** see-through walls from outside (standing outside with the door closed is **stable** — + user visual gate), **NOT** the outdoor node, **NOT** a root toggle, **NOT** nondeterminism. +- `PortalVisibilityBuilder.Build` is a **pure deterministic** function (proved by + `PortalVisibilityBuilderTests.Build_IsDeterministic_*`, passes). So the flip requires a **varying + input**. +- The high-precision `[pv-input]` probe (6 dp) shows the camera eye AND the **player `RenderPosition`** + carry perpetual **~1–8 µm** float jitter at rest (e.g. player Z `94.000000 ↔ 94.000008`). At the + threshold a grazing portal's clip is so knife-edge that this µm jitter flips its empty/non-empty + result → the flood membership flips → the flap. + +**Mechanism chain:** +`physics resting position blips ~µm → ComputeRenderPosition Lerp surfaces it as µm eye jitter → the +portal-flood clip (clip-non-empty membership) is µm-sensitive at the grazing threshold portal → flips → +flap.` Retail is flap-free because its authoritative local position is bit-stable at rest (so its same +clip-non-empty membership never crosses the boundary). + +## 2. REFUTED — the 2026-06-07 cutover-flip diagnosis (do NOT act on its F1/F2) + +`docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md` is wrong on its +load-bearing claims (primary evidence in this session): + +- "See-through from outside" — **not reproduced** (outside, door closed, is stable). +- "Walls ARE the EnvCell shells; ModelId is a partial frame" — **refuted**: the cottage ModelId GfxObj + `0x01000A2B` is a full closed exterior (76 render polys, bbox 20×18×10.4 m, 46 outward-facing walls + + roof — `tools/A8CellAudit gfxobj 0x01000A2B`). EnvCell shells are interior-facing. **F2 (EnvCell + back-faces) targets the wrong geometry.** +- "Oscillation = outdoor-node flood (1↔13)" — **corrected**: it is the *indoor* flood, stable root, + 2↔6. F1 targeted the wrong root. +- "branch=RetailPViewInside every frame proves the flap is gone" — **tautological** (post-flip + `clipRoot = viewerRoot ?? _outdoorNode` is ~never null, so `branch` can't report `OutdoorRoot`). + +## 3. REFUTED — enqueue-once traversal (TDD caught it) + +Hypothesis: the flap is acdream's `MaxReprocessPerCell` re-enqueue drift; restore retail's enqueue-once +(first-discovery only, no re-enqueue). **Refuted:** retail does NOT stop at first discovery — its +`AddViewToPortals` growth branch calls **`AddToCell`** (decomp :433494), so a cell's later-grown view +IS propagated (late slices reach exit portals). The existing test +`PortalVisibilityBuilderTests.Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` encodes exactly +this retail behavior; enqueue-once broke it. The change + its test were reverted (tree clean, 27 portal +tests green). **The divergence is the re-clip DRIFT, not the propagation — and underneath, the flap is +the µm input jitter (which removing the drift would only *reduce*, not eliminate; `Build` is +deterministic so only a bit-stable INPUT guarantees no flap).** + +## 4. The root — physics resting position not bit-stable + +`PlayerMovementController.ComputeRenderPosition` (line 810): `Vector3.Lerp(_prevPhysicsPos, +_currPhysicsPos, alpha)`. `Lerp(a, a, t) == a` exactly, so the µm `RenderPosition` jitter means +**`_prev != _curr`** — the physics body's resting position blips ~µm between ticks. Retail's +`kill_velocity` (`OBJECTINFO::kill_velocity` = `set_velocity(0)`, decomp :274467) is called by +`validate_transition` (:272567) on **every** grounded collision/slide with a valid contact plane, +keeping rest bit-stable. + +acdream rest path: +- `calc_acceleration` (PhysicsBody.cs:191) zeroes gravity only when **`Contact && OnWalkable && + !Sledding`**. +- `UpdatePhysicsInternal` (PhysicsBody.cs:352) skips position integration when `velocity <= 0`. +- Player flags set per tick in `PlayerMovementController` (1271-1301): `Contact|OnWalkable` only when + `resolveResult.IsOnGround && Velocity.Z <= 0`; else cleared → gravity. +- acdream's `kill_velocity` (PhysicsEngine.cs:837) is **narrower than retail's** — fires only on + `ObjectInfo.VelocityKilled` (the airborne steep-roof/wall reset), NOT on every grounded contact. + +So at a *clean* rest the position is bit-stable; the blip is an **intermittent** failure (a stray +gravity tick / µm velocity residual / contact-plane not re-established). The `[resolve]` probe (3 dp) +shows the body stable to **mm** at spawn rest (`94.000` repeated) — confirming the blip is **sub-mm**, +below that probe's precision — and shows `groundedIn=True` but `walkable=False cp=none` (no contact +plane established at rest), a lead toward the Contact/contact-plane path. + +## 5. NEXT STEPS (the physics rest-stability fix) + +1. **Higher-precision physics rest trace (REQUIRED before fixing).** The 3-dp `[resolve]` probe is too + coarse. Add a 6-dp per-tick probe of the resting body: `_body.Position`, `Velocity`, `Acceleration`, + `TransientState` (Contact/OnWalkable), `resolveResult.IsOnGround`, contact-plane valid. Launch, let + the character sit at spawn (no input needed — autonomous), capture ~10 s, and find the tick where the + position blips µm and which condition failed (gravity applied? velocity residual? resolve re-snap? + Contact cleared?). +2. **Port the retail-faithful rest-stability fix** for the pinned cause — most likely one of: + (a) broaden `kill_velocity` to match retail's `validate_transition` (zero velocity on every grounded + contact with a valid contact plane, :272567); (b) ensure the `Contact` flag / contact plane is + re-established on the zero-distance rest sweep so `calc_acceleration` keeps gravity off; (c) a + retail-faithful "supported body at rest is frozen" (skip integration/resolve when grounded + zero + velocity + no movement input). TDD: a test asserting the resting body position is **bit-stable across + N ticks** with no input. +3. **Visual gate** at the cottage doorway threshold: hold still — the 2↔6 oscillation is gone (re-run + `[pv-input]`/`[render-sig]`, flood `ids=` constant at rest). + +**DO NOT RETRY:** the overlap-predicate render band-aid (rejected by user — not retail); enqueue-once +(refuted, §3); any render-side debounce/grace (forbidden). + +## 6. Apparatus (committed this session) + state + +- **Keep (real regression value):** `PortalVisibilityBuilderTests.Build_IsDeterministic_*` (proves Build + deterministic); `tools/A8CellAudit` `gfxobj` mode (dumps render geometry — used to refute the ModelId + claim). +- **Diagnostic probes (env-gated, inert off; KEEP for the physics trace + flap visual gate, strip after + the fix ships):** `[pv-input]` (`ACDREAM_PROBE_PVINPUT`, 6-dp Build inputs + flood count, + RenderingDiagnostics + GameWindow); the `outRoot=`/`bshell=` fields on `[render-sig]`; + `launch-pvinput.ps1`, `launch-bshell-probe.ps1`, `launch-resolve.ps1`. +- Tree: PortalVisibilityBuilder.cs reverted to the re-enqueue (no functional change shipped). Build + green; App.Tests green (portal-visibility 27/27). +- Memory to update: `project_indoor_flap_rootcause` (root is the physics rest µm-jitter, not the render + diagnosis or enqueue-once). diff --git a/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md index 08621cc2..359a7ae8 100644 --- a/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md +++ b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md @@ -2,7 +2,12 @@ **Date:** 2026-06-08 **Branch:** `claude/thirsty-goldberg-51bb9b` -**Status:** design approved (user, 2026-06-08); TDD implementation pending behind a visual gate. +**Status:** ⚠️ **§4 (enqueue-once) REFUTED 2026-06-08** — retail propagates late slices via `AddToCell` +(decomp :433494); the existing `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` test encodes +that and enqueue-once broke it (reverted). The flap's confirmed root is the **physics resting position +µm-jitter** (§6 contingency, now the active direction). **CANONICAL PICKUP:** +`docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md`. Keep §1–§3 (mechanism + retail +grounding) as accurate diagnosis; treat §4–§5 as a refuted approach. --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a1a96a34..a0b7aa60 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7560,6 +7560,19 @@ public sealed class GameWindow : IDisposable clipAssembly = pviewResult.ClipAssembly; envCellShellFilter = pviewResult.DrawableCells; _interiorPartition = pviewResult.Partition; + + // Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp + + // the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player + // identical to cm; this answers whether the inputs differ sub-cm (jitter) or are + // byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled. + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled && pvFrame is not null) + { + var vp = envCellViewProj; + char pvOutRoot = ReferenceEquals(clipRoot, _outdoorNode) ? 'Y' : 'n'; + Console.WriteLine(System.FormattableString.Invariant( + $"[pv-input] outRoot={pvOutRoot} flood={pvFrame.OrderedVisibleCells.Count} eye=({camPos.X:F6},{camPos.Y:F6},{camPos.Z:F6}) player=({playerViewPos.X:F6},{playerViewPos.Y:F6},{playerViewPos.Z:F6}) vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); + } + sigPvFrame = pviewResult.PortalFrame; sigClipAssembly = pviewResult.ClipAssembly; sigDrawableCells = pviewResult.DrawableCells; @@ -9190,6 +9203,22 @@ public sealed class GameWindow : IDisposable sb.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount); sb.Append(" liveDynDraw=").Append(liveDynamicDrawnCount); + // Diagnosis 2026-06-07: draw-vs-occlude probe for the see-through residual. + // outRoot=Y means clipRoot is the synthetic outdoor node (eye outdoors). bshell=total/withMesh + // counts the building ModelId exterior shells queued in partition.Outdoor for this frame — the + // GfxObj exteriors that SHOULD draw the solid walls. Correlate with ids= (the flooded interior + // cells): if bshell=N/N and ids=[node only] but the wall is still see-through, the exterior is + // failing to rasterize (draw/clip bug, not EnvCell sidedness); if ids includes interior cells, + // the outdoor flood is drawing interiors over the exterior. + sb.Append(" outRoot=").Append(ReferenceEquals(clipRoot, _outdoorNode) ? 'Y' : 'n'); + if (partition is not null) + { + int shellTotal = 0, shellMesh = 0; + foreach (var e in partition.Outdoor) + if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; } + sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh); + } + if (outdoorPortalDrawn || exteriorPvFrame is not null || exteriorClipAssembly is not null) { sb.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n'); diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index ad6b7793..03621062 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -129,6 +129,20 @@ public static class RenderingDiagnostics public static bool ProbeShellEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SHELL") == "1"; + /// + /// Flap root-cause apparatus (2026-06-07). When true, the indoor render path emits ONE + /// [pv-input] line per frame with the EXACT PortalVisibilityBuilder.Build inputs at HIGH + /// precision (camera eye + player position to 6 dp, plus orientation-sensitive view-projection + /// elements) alongside the resulting flood cell count. The live flap shows the flood set flipping + /// 2↔6 at an eye/player that is identical to cm; this probe answers whether the Build INPUTS differ + /// below cm precision (sub-cm view jitter → robustness fix) or are byte-identical while the output + /// still flips (nondeterminism → surgical bug). Runs WITHOUT the heavy [flap]/[render-sig] + /// spam so the log stays diffable. Throwaway apparatus — strip once the jitter source is pinned. + /// Initial state from ACDREAM_PROBE_PVINPUT=1. + /// + public static bool ProbePvInputEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_PVINPUT") == "1"; + // Cell-change gate for EmitVis. The probe fires once per distinct root cell // so launch.log stays readable under motion (the per-frame call is a no-op // when the root is unchanged). Sentinel 0 = "no root yet" — the first real diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 746a6f44..38818c92 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -58,6 +58,38 @@ public class PortalVisibilityBuilderTests $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); } + [Fact] + public void Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet() + { + // Flap root-cause apparatus (2026-06-07): the live threshold flap shows OrderedVisibleCells + // flipping 2<->6 at an eye+player identical to cm. Build is a pure static function with + // all-fresh per-call state, so identical inputs MUST yield an identical visible set. If this + // FAILS, the flap is a determinism bug INSIDE Build; if it PASSES (expected), the live flip is + // sub-cm INPUT jitter and the fix must make membership robust, not Build deterministic. + // Exercises the re-enqueue fixpoint via a diamond: 0x0004 is reached from BOTH 0x0002 and 0x0003. + var cam = Cell(0x0001, + new CellPortalInfo(0x0002, 0, 0, 0), + new CellPortalInfo(0x0003, 1, 0, 0)); + cam.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -3f)); // left -> 0002 + cam.PortalPolygons.Add(QuadX(0.05f, 0.6f, -3f)); // right -> 0003 + var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0)); + left.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -6f)); + var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0)); + right.PortalPolygons.Add(QuadX(0.05f, 0.6f, -6f)); + var back = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0)); + back.PortalPolygons.Add(QuadX(-0.6f, 0.6f, -9f)); + var all = new Dictionary + { [0x0001] = cam, [0x0002] = left, [0x0003] = right, [0x0004] = back }; + + var a = Build(cam, all); + var b = Build(cam, all); + + Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells); + Assert.Equal( + a.CellViews.Keys.OrderBy(k => k).ToArray(), + b.CellViews.Keys.OrderBy(k => k).ToArray()); + } + [Fact] public void Build_EyeStandingInInteriorPortal_FloodsNeighbour() { diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index 872eef80..d3948ac8 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -30,6 +30,14 @@ else if (args.Length > 0 && string.Equals(args[0], "portals", StringComparison.O foreach (var envCellId in ids) DumpCellPortals(dats, envCellId); } +else if (args.Length > 0 && string.Equals(args[0], "gfxobj", StringComparison.OrdinalIgnoreCase)) +{ + var ids = args.Length == 1 + ? new uint[] { 0x01000A2Bu } + : args.Skip(1).Select(ParseHex).ToArray(); + foreach (var gfxObjId in ids) + DumpGfxObj(dats, gfxObjId); +} else { var ids = args.Length == 0 @@ -365,6 +373,98 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo return (registryBuildingId - 1, shellEntities); } +static void DumpGfxObj(DatCollection dats, uint gfxObjId) +{ + Console.WriteLine($"=== GfxObj 0x{gfxObjId:X8} (RENDER polygons) ==="); + var g = dats.Get(gfxObjId); + if (g is null) + { + Console.WriteLine("missing GfxObj"); + return; + } + + var verts = g.VertexArray.Vertices; + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var v in verts.Values) + { + min = Vector3.Min(min, v.Origin); + max = Vector3.Max(max, v.Origin); + } + var centroid = (min + max) * 0.5f; + + int walls = 0, floors = 0, ceilings = 0, slopes = 0; + int outwardWalls = 0, inwardWalls = 0; + int emitPos = 0, emitNeg = 0, skipped = 0; + + foreach (var (polyId, poly) in g.Polygons.OrderBy(p => p.Key)) + { + if (poly.VertexIds.Count < 3) continue; + + bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) + || poly.Stippling.HasFlag(StipplingType.Both) + || (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); + if (hasPos) emitPos++; + if (hasNeg) emitNeg++; + if (!hasPos && !hasNeg) skipped++; + + var n = ComputeNormalG(g, poly); + bool isWall = Math.Abs(n.Z) <= 0.15f; + bool isFloor = n.Z > 0.9f; + bool isCeiling = n.Z < -0.9f; + if (isFloor) floors++; + else if (isCeiling) ceilings++; + else if (isWall) walls++; + else slopes++; + + if (isWall) + { + var pc = PolyCentroidG(g, poly); + var toFace = pc - centroid; + float outward = Vector3.Dot(n, toFace); // >0 => front face points away from center (exterior) + if (outward > 0) outwardWalls++; else inwardWalls++; + } + } + + Console.WriteLine( + $"verts={verts.Count} renderPolys={g.Polygons.Count} hasPhysics={(g.PhysicsPolygons?.Count ?? 0)} " + + $"emitPos={emitPos} emitNeg={emitNeg} skipped={skipped}"); + Console.WriteLine( + $"bbox min=({min.X:F2},{min.Y:F2},{min.Z:F2}) max=({max.X:F2},{max.Y:F2},{max.Z:F2}) " + + $"size=({max.X - min.X:F2},{max.Y - min.Y:F2},{max.Z - min.Z:F2})"); + Console.WriteLine( + $"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " + + $"floors={floors} ceilings={ceilings} slopes={slopes}"); + Console.WriteLine(); +} + +static Vector3 ComputeNormalG(GfxObj g, DatReaderWriter.Types.Polygon poly) +{ + if (poly.VertexIds.Count < 3) return Vector3.Zero; + if (!g.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var a) || + !g.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var b) || + !g.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var c)) + { + return Vector3.Zero; + } + var n = Vector3.Cross(b.Origin - a.Origin, c.Origin - a.Origin); + return n.LengthSquared() > 0f ? Vector3.Normalize(n) : Vector3.Zero; +} + +static Vector3 PolyCentroidG(GfxObj g, DatReaderWriter.Types.Polygon poly) +{ + var sum = Vector3.Zero; + int count = 0; + foreach (var vid in poly.VertexIds) + if (g.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) + { + sum += v.Origin; + count++; + } + return count > 0 ? sum / count : Vector3.Zero; +} + static bool IsSupported(uint id) { uint type = id & 0xFF000000u; From 6c3a96b26eef61dfaeedcb4dba887c9f0da01b63 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 11:21:46 +0200 Subject: [PATCH 045/172] diag(render): flap re-diagnosed as portal-flood re-clip DRIFT; physics + camera REFUTED The 2026-06-08 AM "physics rest micro-jitter" diagnosis is refuted with primary evidence (door-recheck 216K standstill records: 0 position re-snaps; player byte-stable during the flap). Two adversarial verification sub-agents confirmed: - Retail roots the render at the camera viewer_cell (swept from the player via SmartBox::update_viewer 0x453ce0; DrawInside(viewer_cell) 0x453aa0) and toggles DrawInside / LScape::draw -- so acdream's eye-cell rooting + inside/outside toggle are RETAIL-FAITHFUL. The locked-design "root at player cell" is wrong. - The flap is render membership instability, eye-motion-driven: the visible-cell set oscillates (8<->3) as the eye sweeps monotonically. Root = the re-enqueue-on-growth DRIFT (PortalVisibilityBuilder.cs:322, MaxReprocessPerCell =16) re-clipping each grown cell every round -> sub-cm eye jitter flips membership. Fix (spec, not yet implemented): verbatim port of retail's enqueue-once flood (ConstructView + AddViewToPortals): enqueue once on first discovery, clip each cell's portals once, union late growth in place (AddToCell) + draw-reorder (FixCellList), never re-enqueue. Kills the drift; rooting/camera/seal untouched. This commit lands VERIFIED GROUNDWORK + design only: - spec: docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md - findings: docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md - [pv-input] probe gains rawPlayer + yaw (disambiguates the varying input) - 4 GREEN physics rest-stability tests (prove rest is bit-stable -> flap not physics) - apparatus: launch-flap-capture.ps1, analyze_flap_live.py, find_burst.py - captured fixtures: tests/.../Fixtures/flap-doorway/0xA9B4017{0..5}.json Co-Authored-By: Claude Opus 4.8 (1M context) --- analyze_flap_live.py | 44 + ...diagnosis-REFUTED-its-render-membership.md | 127 + ...8-portal-flood-enqueue-once-port-design.md | 219 + find_burst.py | 30 + launch-flap-capture.ps1 | 23 + src/AcDream.App/Rendering/GameWindow.cs | 8 +- .../Fixtures/flap-doorway/0xA9B40170.json | 413 ++ .../Fixtures/flap-doorway/0xA9B40171.json | 3773 +++++++++++++++++ .../Fixtures/flap-doorway/0xA9B40172.json | 1963 +++++++++ .../Fixtures/flap-doorway/0xA9B40173.json | 413 ++ .../Fixtures/flap-doorway/0xA9B40174.json | 583 +++ .../Fixtures/flap-doorway/0xA9B40175.json | 413 ++ .../Input/PlayerMovementControllerTests.cs | 91 + .../Physics/CellarUpTrajectoryReplayTests.cs | 132 + 14 files changed, 8231 insertions(+), 1 deletion(-) create mode 100644 analyze_flap_live.py create mode 100644 docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md create mode 100644 docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md create mode 100644 find_burst.py create mode 100644 launch-flap-capture.ps1 create mode 100644 tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40170.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40171.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40172.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40173.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40174.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40175.json diff --git a/analyze_flap_live.py b/analyze_flap_live.py new file mode 100644 index 00000000..b1b32b45 --- /dev/null +++ b/analyze_flap_live.py @@ -0,0 +1,44 @@ +import sys, re, math +from collections import Counter + +pat = re.compile( + r'outRoot=(\w) flood=(\d+) eye=\(([^)]+)\) player=\(([^)]+)\) ' + r'rawPlayer=\(([^)]+)\) yaw=([-\d.]+)') +rows = [] +for l in sys.stdin: + m = pat.search(l) + if not m: + continue + rows.append(( + m.group(1), int(m.group(2)), + tuple(float(x) for x in m.group(3).split(',')), # eye + tuple(float(x) for x in m.group(4).split(',')), # player (RenderPosition) + tuple(float(x) for x in m.group(5).split(',')), # rawPlayer (physics body) + float(m.group(6)))) # yaw +print("parsed pv-input rows:", len(rows)) +if not rows: + raise SystemExit + +print("flood histogram (outRoot,flood)->count:", dict(Counter((r[0], r[1]) for r in rows))) + +def rng(idx): + return [max(r[idx][k] for r in rows) - min(r[idx][k] for r in rows) for k in range(3)] +print(f"eye range over window (m): {[round(v,6) for v in rng(2)]}") +print(f"render-pos range over window (m): {[round(v,6) for v in rng(3)]}") +print(f"raw-phys range over window (m): {[round(v,6) for v in rng(4)]}") +print(f"yaw range over window (rad): {round(max(r[5] for r in rows)-min(r[5] for r in rows),6)}") + +flips = 0 +samples = [] +for i in range(1, len(rows)): + a, b = rows[i-1], rows[i] + if a[1] == b[1]: + continue + flips += 1 + ed = math.dist(a[2], b[2]); pd = math.dist(a[3], b[3]) + rd = math.dist(a[4], b[4]); yd = abs(b[5]-a[5]) + if len(samples) < 18: + samples.append(f"{b[0]} {a[1]}->{b[1]:<2} eye={ed*1000:7.3f}mm rend={pd*1e6:8.1f}um raw={rd*1e6:8.1f}um yaw={yd*1000:8.4f}mrad") +print(f"flood flips in window: {flips}") +for s in samples: + print(" ", s) diff --git a/docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md b/docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md new file mode 100644 index 00000000..8e6b733e --- /dev/null +++ b/docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md @@ -0,0 +1,127 @@ +# Handoff/Findings — the "physics rest µm-jitter" flap diagnosis is REFUTED; the flap is a RENDER membership instability at the grazing doorway portal — 2026-06-08 (PM) + +> **Supersedes** `docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md` (the AM handoff). That +> handoff asked to be treated as a suspect statement and verified — this session verified it with primary +> evidence and it does **not** hold. The flap is **not physics** and **not camera drift**. It is the +> portal-flood **membership flickering non-monotonically at the grazing doorway portal as the camera eye +> sweeps** (i.e. while you turn the camera at the doorway). This is the SPEC's §2.2 diagnosis +> (`2026-06-08-portal-flood-membership-stability-design.md`), NOT its refuted §4 enqueue-once fix. + +--- + +## 1. What was claimed (AM handoff) vs what the evidence shows + +**AM claim:** the flap's varying input is a **physics resting-position µm jitter** — `_body.Position` +blips ~1 ULP between ticks at rest → `RenderPosition` (Lerp of physics) jitters → eye jitters → flood +flips. Fix = physics rest-stability (broaden `kill_velocity`, hold contact plane). + +**Refuted by three independent pieces of primary evidence:** + +### A. The physics body is bit-stable at standstill — it does NOT blip +`door-recheck-capture.jsonl` (515 MB, 238,342 `ResolveWithTransition` records, captured standing at the +doorway, cells `0xA9B40170/0171/0174/0175/0031`): +- **216,300 true-standstill records** (zero velocity, `currentPos==targetPos`). +- **0** resolve re-snaps (`result.position != input` never happens at standstill). +- **0** cross-tick `currentPos` drift (the body position is carried forward byte-identically). +- The `grounded-but-cp=none` contradictory state DOES occur (3.5% of frames) but produces **no** position + blip. + +Confirmed independently with **4 new deterministic tests** (all GREEN — they PROVE rest is bit-stable): +- `PlayerMovementControllerTests.Update_AtRestNoInput_RenderPositionBitStableAcrossManyFrames` (flat terrain) +- `PlayerMovementControllerTests.Update_WalkThenStop_SettlesToBitStableRest` (flat terrain, post-motion) +- `CellarUpTrajectoryReplayTests.IndoorCellarFloor_AtRestZeroOffset_BodyPositionBitStable` (indoor cell, resolver loop) +- `CellarUpTrajectoryReplayTests.IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable` (indoor cell, full controller loop) + +### B. On the actual flap frames, the PLAYER position is byte-identical +`pvinput.log` window 7748–7758 (a clean `flood 6↔2` flap), player `RenderPosition` for **11 consecutive +frames**: `(155.632858, 13.527222, 94.000000)` — **byte-identical**. The physics output the camera reads +does not move at all during the flap. (The 1-ULP *player* blip the AM handoff cited is at the **outdoor** +`flood=1` records — a red herring, not the indoor flap.) + +### C. The EYE moves while the player is still — and the camera DOES settle when idle +Same window: the eye orbits smoothly ~1 mm/frame (X ↓, Y ↑, Z constant) — a slow **camera rotation** +around the stationary player. And: +- Of **888** flood flips in the capture, only **1** had a byte-identical eye (and that one is the + outdoor→indoor root switch). Every other flip had a moved eye → **the flood is deterministic in the + eye; it changes only when the eye moves** (matches `Build_IsDeterministic_*`). +- Longest **indoor byte-identical-eye runs: 203 / 181 / 178 frames (~3.4 s)** — within each, the flood is + a **single constant value** (no flicker). **61%** of indoor frames have a byte-stable eye. +- ⇒ The camera **settles** at rest (no boom drift, no spring oscillation). When the eye is still, the + flood is stable. The flap fires **only while the eye is moving**. + +## 2. The actual mechanism + +When the camera eye **sweeps** through the grazing doorway portal (you turn the camera at the threshold), +the deep cell cluster `{0172,0173,0174,0175}` flickers in/out — flood `6,6,6,2,2,6,6,6,2,6,2` — i.e. +**non-monotonic membership across a monotonic eye sweep**. A correct visibility flood would transition +the deep cluster in/out **once** as the grazing portal closes; instead its clip flips empty↔non-empty as +the eye crosses and re-crosses the knife-edge. This is the SPEC's §2.2 diagnosis (the grazing portal's +clip / re-clip drift makes `clippedRegion.Count` flip `0↔N`, dropping the deep cluster on empty-clip +frames). + +It is **NOT** physics (A, B). It is **NOT** camera drift/oscillation (C: eye byte-stable ~3.4 s when +idle). It is a **render-side portal-flood membership instability at grazing angles**, surfaced by camera +rotation. + +## 3. Status of prior fixes / diagnoses + +- **AM physics-rest fix** — would not have fixed the flap (physics rest is already bit-stable). Do not pursue. +- **SPEC §2.2 diagnosis (grazing-portal membership instability)** — CONFIRMED by this evidence. +- **SPEC §4 enqueue-once fix** — already refuted in the AM handoff (retail propagates late slices via + `AddToCell`, decomp :433494; broke `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`). So the + correct fix is a render-side membership stabilization that is monotonic under a sweeping eye **without** + breaking late-slice propagation — design TBD (brainstorm). + +## 4. Apparatus (this session) +- 4 GREEN rest-stability tests (above) — keep as regression guards + evidence that physics rest is bit-stable. +- Analysis scripts (ad-hoc, `/tmp`): door-recheck standstill survey; pvinput flood-flip vs eye/player + delta buckets; indoor byte-stable-eye-run scan. Re-derivable from `pvinput.log` + `door-recheck-capture.jsonl`. +- Existing probes `[pv-input]` (`ACDREAM_PROBE_PVINPUT`) and `[render-sig]` remain the live gate. + +## 5. Next step (proposed) +Brainstorm + design a render-side fix that makes the deep-cluster membership **monotonic/stable as the eye +sweeps the grazing portal** (candidates: more robust grazing-portal clip, a retail-faithful single-process +traversal that doesn't re-clip-drift, or matching retail's exact `GetClip`/`polyClipFinish` epsilon). Then +TDD a builder test that sweeps the eye across the grazing angle and asserts monotonic membership, and +visual-gate by turning the camera at the cottage doorway. + +## 6. LIVE-CONFIRMED (2026-06-08 PM, targeted doorway capture, user-driven) + +A fresh instrumented capture (`launch-flap-capture.ps1`; `[pv-input]` enhanced with `rawPlayer`=raw +physics body pos + `yaw`; cells `0170-0175` dumped to `tests/.../Fixtures/flap-doorway/`; 84K frames) +confirms the diagnosis across every state and decomposes the flap into THREE render sub-issues. **In +every case the player render-pos AND raw physics-pos are byte-identical (0 µm) — physics is conclusively +exonerated; the flap is 100% camera-eye-driven.** + +| State (user-driven) | player moves | eye moves | flood | +|---|---|---|---| +| Idle, hands fully off | no (0 µm) | **no (0 µm)** | **stable** (no flap) | +| Turn / walk | no (0 µm) | yes (mm, yaw) | oscillates | +| Camera smoothing-glide after a turn (yaw byte-constant, eye glides monotonically, decelerating) | no | yes (mm) | **oscillates 8↔3** ← this is the "flickers while idle" the user perceived | + +Key burst (row 11167): **yaw byte-constant**, eye X glides monotonically 155.109→155.435 (18→5 mm/frame, +decelerating), flood `8→3→8…3→8`. Monotonic eye ⇒ non-monotonic membership ⇒ **render** instability (not +camera hunting). + +**Three sub-issues (all eye-driven, physics out):** +- **A — Membership oscillation:** flood non-monotonic as the eye sweeps within a *stable* root. + outside-looking-in `8↔3`; outdoor-root `17↔33` (21 flips/2500 frames); indoor-root `2↔6`. +- **B — Root toggle (the big one):** at the threshold, `outRoot` flips outdoor↔indoor as the eye crosses + the door plane → wholesale visible-set swap **≈18-33 cells ↔ 2 cells** (4 toggles in 2500 frames). This + is the "two-branch" outdoor-node-vs-indoor-cell root switch the unification was meant to remove — still + present. +- **C — Indoor-root under-inclusion:** eye just inside ⇒ `outRoot=n` flood **= 2, stable** for 2438 + frames → outdoors + other rooms missing (the indoor flood does not reach back out the exit portal / to + adjacent cells). C is B's partner: the swap *to* indoor loses the scene → "textures missing." + +**Fix scope:** core render pipeline (root resolution + flood + grazing-clip), NOT physics, NOT camera. +Spec §2.2 (membership instability) is right for A; B+C are the threshold root-resolution/flood issues. +Spec §4 enqueue-once stays refuted. Design needs brainstorming (saga has reverted speculative render +fixes — see `feedback_render_one_gate`, `feedback_verify_render_seal_before_layering`). + +Apparatus added: `launch-flap-capture.ps1`, `analyze_flap_live.py`, `find_burst.py`, fixtures +`tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B4017{0..5}.json`, `flap-doorway-resolve.jsonl`. + +**Memory to correct:** `project_indoor_flap_rootcause` (root is render: A membership instability + B +root-toggle + C indoor under-inclusion, all under a moving camera eye — NOT physics rest, NOT camera +drift; the "two-branch split" B is still live). diff --git a/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md b/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md new file mode 100644 index 00000000..4b7ca68e --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md @@ -0,0 +1,219 @@ +# Portal-Flood Enqueue-Once Port — the indoor "flap" fix (verified design) + +**Date:** 2026-06-08 +**Branch:** `claude/thirsty-goldberg-51bb9b` +**Status:** Design approved (brainstorm). Pending implementation plan. + +> **Supersedes** the enqueue logic in `docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md` +> (whose §4 enqueue-once was an *incomplete* attempt — it dropped the `AddToCell` growth half) and the +> physics-rest direction in `docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md` (refuted). +> **Diagnosis evidence:** `docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md` +> + this session's two adversarial verification agents (retail decomp + acdream code/data). + +--- + +## 1. Summary + +The indoor render **flap** (interior textures battling / popping in and out at a building doorway) is a +**render-side portal-flood membership instability**: as the camera **eye** moves (turning the camera, or +the camera's smoothing-glide after a turn), the set of cells the flood deems visible **oscillates** +(e.g. `8↔3`) even though the eye sweeps **monotonically**. The root is acdream's **re-enqueue-on-growth +"drift"** in `PortalVisibilityBuilder.Build` (`cs:322`, `MaxReprocessPerCell = 16`): a cell whose view +grows is re-enqueued and its portals **re-clipped from the grown (drifted) view** each round; under +sub-cm eye motion each frame re-clips slightly differently → the visible set flips. + +The fix is a **verbatim port of retail's enqueue-once portal traversal** (`PView::ConstructView` + +`AddViewToPortals`): a cell is enqueued **only on first discovery**; its portals are clipped **exactly +once** (at pop); later growth into an already-discovered cell is unioned **incrementally in place** +(`AddToCell`) and its draw-list slot re-ordered (`FixCellList`) — **never re-enqueued, never re-clipped +from scratch**. This makes the visible set a deterministic function of the **root + geometry**, so it no +longer drifts with eye jitter. Localized to `PortalVisibilityBuilder`. No camera, physics, rooting, clip- +math, or seal change. + +--- + +## 2. Root cause — verified this session + +### 2.1 What the flap is NOT (refuted with primary evidence) +- **Not physics.** `door-recheck-capture.jsonl`: **216,300 standstill physics records, 0 position + re-snaps** — the body is byte-stable at rest. Deterministic tests (flat terrain + indoor cell, resolver + + full controller) confirm: a resting body holds a byte-identical position. The 2026-06-08 AM + "physics rest µm-jitter" diagnosis is refuted. +- **Not the camera rooting or the inside/outside toggle.** Verified against retail (agent 1): + `SmartBox::RenderNormalMode` (0x453aa0) calls **`DrawInside(viewer_cell)`** (decomp 92675), and + `SmartBox::update_viewer` (0x453ce0) sets `viewer_cell` from a **swept `CTransition`** seeded at the + **player's cell** (`init_path(cell_1, …)` 92866 → `viewer_cell = sphere_path.curr_cell` 92871). So + rooting at the camera's `viewer_cell` and toggling `DrawInside`/`LScape::draw` are **retail-faithful**. + The locked-design claim "root at the player cell" (`2026-06-02 …redesign-design.md` §1.5) is **wrong**; + acdream's current `clipRoot = viewerRoot ?? _outdoorNode` (eye-cell rooting) is correct and stays. +- **Not camera drift at rest.** When the eye is byte-stable (hands-off idle), the flood is rock-stable + (203/181/178-frame byte-identical-eye runs hold a single flood value). The camera settles; the flap + fires **only while the eye moves**. + +### 2.2 What the flap IS (verified — agent 2 + live capture) +- The flood oscillates **only when the eye moves**: across ~7,800 flood flips, **3** had a byte-identical + eye (all startup/streaming); **~87 %** of eye-motion flips have a **byte-identical player** position. + A clean burst (yaw byte-constant, eye gliding monotonically 18→5 mm/frame as the camera settles) shows + flood `8→3→8…` — **non-monotonic membership under a monotonic eye sweep**. +- The mechanism is the **re-enqueue/re-clip drift**: `PortalVisibilityBuilder.cs:322` + `if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) + todo.Insert(neighbour, dist);` re-enqueues a grown neighbour up to 16×; each re-process re-clips the + cell's portals from its grown view, so sub-cm eye jitter walks `ClipToRegion`'s surviving-vertex count + across the empty/non-empty boundary → the deep cluster `{0172-0175}` drops/returns → the flap. +- **Sub-issue "C" (indoor flood=2 / "missing textures") is mostly a *symptom* of this drift**, not a + missing seal: the landscape-through-the-door seal **is** present in the indoor path + (`RetailPViewRenderer.DrawInside` → `DrawLandscapeThroughOutsideView`). When the flood drops `8→3`, + the `OutsideView`/terrain/cell clip shrinks → things vanish. Fixing the drift removes the symptom. + +--- + +## 3. Retail grounding (the traversal being ported) + +All from `docs/research/named-retail/acclient_2013_pseudo_c.txt`: + +- **`PView::ConstructView`** (0x5a57b0, :433750): `InitCell(root)` + `InsCellTodoList(root)`, then a loop + that **pops one cell at a time** from the todo list, **appends it to the draw list** (← that is + membership), sets `cell_view_done = 1` (:433784), runs `ClipPortals` once, then `AddViewToPortals`. +- **`PView::AddViewToPortals`** (0x5a52d0, :433446): for each visible portal to a neighbour, three cases + keyed on the neighbour's stamps (`processed_stamp` = `*(view+0x44)`, `view_stamp` = `*(view+0x38)`): + - **First discovery** (`processed_stamp == 0`, :433478): `InitCell(neighbour)` + `InsCellTodoList` + (**enqueue once**). + - **Growth** (`processed_stamp != view_stamp`, :433492): `AddToCell(neighbour)` + if already drawn + `FixCellList`; then `processed_stamp = view_stamp`. **No re-enqueue. No re-clip from scratch.** + - **Already current** (`processed_stamp == view_stamp`): **nothing**. +- **`PView::AddToCell`** (0x5a4d90, :433050): clips the cell's portals against **only the newly-added + view slices** (`for i = esi[0x11]; i < esi[0xe]`) — an **incremental** union, not a full re-clip; it + does **not** re-contribute to `OutsideView`. +- **`PView::FixCellList`** (0x5a5250, :433407) → `AdjustDrawList` (:433107): **re-orders** the grown cell + in the draw list to preserve draw order. No re-flood. +- **`PView::InitCell`** (0x5a4b70, :432896): seeds the cell's view, clips its portals against the full + incoming view, stamps with `master_timestamp`; returns whether the cell is non-empty (→ enqueue). + +So retail clips each cell's portals **exactly once** (at pop). Late growth refines a cell's own view + +draw order, never its downstream flood. This is the `cell_view_done` "process each cell once" guarantee. + +--- + +## 4. The fix (design) + +**Scope: `PortalVisibilityBuilder.Build` only.** Replace the re-enqueue-on-growth fixpoint with retail's +enqueue-once traversal. Concretely: + +**Change A — enqueue-once (`Build` ~308-328).** Today: + + var nview = GetOrCreate(frame.CellViews, neighbourId); + bool grew = AddRegion(nview, clippedRegion); // union in place (= retail AddToCell) + if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) + todo.Insert(neighbour, dist); // RE-ENQUEUE on growth ← the drift + +New: enqueue a neighbour into `todo` **only on first discovery** — i.e. when it has **no `CellViews` +entry yet** (retail `processed_stamp == 0` → `InitCell` + `InsCellTodoList`). On growth into an +already-discovered neighbour, **keep `AddRegion`** (incremental union = `AddToCell`) and re-order it in +the draw list if already present (`FixCellList`, §Change C), but **do not** re-insert into `todo`. + +**Change B — remove the re-enqueue machinery.** Delete `MaxReprocessPerCell`, `popCounts`, and the +per-pop re-enqueue / `queued`-reset logic in the pop loop. Termination is now by construction (each cell +enqueued ≤1, popped ≤1; ≤N cells total), matching retail `cell_view_done`. The `MaxReprocessPerCell` cap +existed **only** as a termination band-aid for the re-enqueue — with enqueue-once it is dead. + +**Change C — draw-list re-order on growth (`FixCellList`).** When growth unions into an +already-discovered cell that is **already in `OrderedVisibleCells`**, re-position it to preserve +closest-first draw order (retail `AdjustDrawList` :433107). If acdream's `OrderedVisibleCells` is already +distance-sorted at assembly time and order is not load-bearing for correctness, this degrades to a no-op +— confirm during implementation; do **not** add ordering machinery the renderer doesn't consume. + +**Unchanged (explicitly):** the per-portal clip (`ProjectToClip`/`ClipToRegion`), the +`EyeInsidePortalOpening` degenerate-portal guard (`Build:235-244`), the reciprocal `OtherPortalClip`, the +`OutsideView` exit contribution, the rooting (`clipRoot = viewerRoot ?? _outdoorNode`), the camera, and +the landscape-through-door seal. No new predicate, no robustness heuristic, no hysteresis. + +**Why this is the flap fix, not a band-aid:** with each cell's portals clipped once, the visible set is a +deterministic function of `(root, geometry)` — independent of the per-round re-clip path. Sub-cm eye +jitter changes the *projection* (and thus what's drawn within each clipped cell, correctly) but no longer +changes *which cells are members*. The membership stops oscillating; the textures stop battling. + +--- + +## 5. The `Build_ViewGrowthAfterDoneCell` question (open item, resolve during implementation) + +The re-enqueue was added 2026-06-07 "to propagate late-discovered slices to exit portals," and +`PortalVisibilityBuilderTests.Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` encodes that. But +the decomp shows retail's `AddToCell` (:433050) only clips the cell's **own** portals against new slices ++ re-orders draw position — it does **not** re-contribute to `OutsideView` (the exit slice is emitted by +`ClipPortals` at pop, once). So "late growth reaches the exit/OutsideView" appears to be **non-retail**. + +**Action:** read `PView::ClipPortals` (the OutsideView contribution site) during implementation to +confirm. If confirmed, this test encodes the non-faithful re-enqueue behavior and is **corrected to +match retail** (late growth refines the cell's view + draw order, not the OutsideView). It will **not** +be satisfied by reinstating the re-enqueue. If the OutsideView tests +(`Builder_Cellar_WindowClippedToStairwell`, look-in tests) shrink, that is the retail behavior, handled +retail's way — not by re-adding the drift. + +--- + +## 6. Testing (TDD) + +The flap manifests only under live µm/mm eye motion at a specific grazing geometry, so the **visual gate +is acceptance**; the unit layer pins determinism + guards regressions. + +1. **Deterministic eye-sweep stability (new, the RED→GREEN driver).** In `AcDream.App.Tests` + (alongside `PortalVisibilityBuilderTests`, since `PortalVisibilityBuilder` is an App-layer type), build + the flood at a sequence of eye positions stepping across the grazing door angle (sub-cm steps + reproducing the live sweep). **Assert each cell's membership across the sweep is a single contiguous + run** — no `present→absent→present` (or `absent→present→absent`) flicker for any cell. That is the + precise anti-flap property (the live capture showed `8→3→8→3`, multiple transitions per cell). RED + under the re-enqueue drift; GREEN after enqueue-once. *Fixture note:* the captured dumps live at + `tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B4017{0..5}.json`; the test must reach them + (shared path or copied into `AcDream.App.Tests/Fixtures`) and the cells must carry the portal graph + + clip planes `Build` consumes. If the cell-dump format omits portals/clip-planes, the impl plan either + extends the dump or synthesizes a minimal doorway portal topology reproducing the grazing geometry — + surface this as the first implementation step, do not silently weaken the test. +2. **Enqueue-once termination + dedup (new).** Diamond (a cell reachable from two parents) + cycle + fixtures: assert the flood terminates with `MaxReprocessPerCell` removed, `OrderedVisibleCells` is + deduped, each reachable cell present exactly once, and (if a per-cell pop counter is cheap to surface) + each cell popped ≤1. +3. **No membership regression.** `Build_IsDeterministic_*`, `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, + `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`, + `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95 guard), and the cellar/window/look-in + tests stay **green**. `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` is handled per §5. +4. **Visual gate (user) — acceptance.** At the cottage doorway: turn the camera back and forth and walk + through — the interior rooms render steadily, no battling/popping; the `[pv-input]` flood is stable + for a given eye pose. Re-run with `launch-flap-capture.ps1`. + +`dotnet build` + `dotnet test` green before the visual gate. + +--- + +## 7. Scope / non-goals + +- **In scope:** `PortalVisibilityBuilder.Build` enqueue logic (enqueue-once; remove + `MaxReprocessPerCell`/`popCounts`/re-enqueue; incremental union on growth; draw-order re-position) + the + new/updated tests; reading `ClipPortals` to settle §5. +- **Non-goals (deferred / untouched):** + - **No rooting change** — eye-cell rooting (`clipRoot = viewerRoot ?? _outdoorNode`) is retail-faithful + (§2.1). The locked design's "root at player cell" is refuted, not implemented. + - **No clip-math change** (`ProjectToClip`/`ClipToRegion`), no `EyeInsidePortalOpening` change, no + overlap predicate, no hysteresis/robustness heuristic. + - **No camera, physics, or seal change.** The landscape-through-door seal already exists; C is a symptom + of the drift and resolves with it. + - The 4 GREEN physics rest-stability tests added this session stay as regression guards (they document + that physics rest is bit-stable → the flap is not physics). + +--- + +## 8. Apparatus + references + +- **Diagnosis + verification:** `docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md`; + this session's two adversarial verification agents (retail decomp CONFIRMED rooting/seal; acdream + code/data CONFIRMED physics-out + eye-driven + the `cs:322` drift). +- **Captured fixtures:** `tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B4017{0..5}.json`; + `flap-doorway-resolve.jsonl`. Apparatus: `launch-flap-capture.ps1`, `analyze_flap_live.py`, + `find_burst.py`, the `[pv-input]` probe (`ACDREAM_PROBE_PVINPUT`, now logs eye/player/rawPlayer/yaw). +- **Retail decomp anchors:** `ConstructView` :433750, `AddViewToPortals` :433446, `InitCell` :432896, + `AddToCell` :433050, `FixCellList` :433407 / `AdjustDrawList` :433107, `InsCellTodoList` :433183, + `SmartBox::update_viewer` :92761, `SmartBox::RenderNormalMode` :92635. +- **Superseded:** `2026-06-08-portal-flood-membership-stability-design.md` §4 (incomplete enqueue-once); + `2026-06-08-flap-rootcause-physics-rest-handoff.md` (physics direction, refuted). +- **Memory to correct after ship:** `project_indoor_flap_rootcause` (root = the `PortalVisibilityBuilder` + re-enqueue/re-clip **drift** under a moving eye; rooting/toggle is retail-faithful; physics + camera + exonerated). diff --git a/find_burst.py b/find_burst.py new file mode 100644 index 00000000..ef58eca9 --- /dev/null +++ b/find_burst.py @@ -0,0 +1,30 @@ +import sys, re +path = sys.argv[1] +pat = re.compile(r'flood=(\d+) eye=\(([^)]+)\) player=\(([^)]+)\) rawPlayer=\(([^)]+)\) yaw=([-\d.]+)') +rows = [] +with open(path, encoding='utf-8', errors='ignore') as fh: + for l in fh: + m = pat.search(l) + if m: + rows.append((int(m.group(1)), + tuple(float(x) for x in m.group(2).split(',')), + float(m.group(5)))) +print("total pv-input rows:", len(rows)) +# find first window of 25 frames containing >=4 flood changes (an oscillation burst) +def changes(seg): return sum(1 for i in range(1, len(seg)) if seg[i][0] != seg[i-1][0]) +W = 25 +start = None +for i in range(len(rows)-W): + if changes(rows[i:i+W]) >= 4: + start = i; break +if start is None: + print("no oscillation burst found"); sys.exit() +print(f"burst at row {start}; dumping {W+8} frames (flood, eyeX,eyeY,eyeZ, dEyeX_mm,dEyeY_mm, yaw):") +prev = None +for r in rows[start:start+W+8]: + fl, e, yaw = r + dx = (e[0]-prev[0])*1000 if prev else 0.0 + dy = (e[1]-prev[1])*1000 if prev else 0.0 + mark = " <" if (prev and fl != prevfl) else "" + print(f" flood={fl} eye=({e[0]:.6f},{e[1]:.6f},{e[2]:.6f}) dX={dx:+7.3f}mm dY={dy:+7.3f}mm yaw={yaw:.6f}{mark}") + prev = e; prevfl = fl diff --git a/launch-flap-capture.ps1 b/launch-flap-capture.ps1 new file mode 100644 index 00000000..7530998a --- /dev/null +++ b/launch-flap-capture.ps1 @@ -0,0 +1,23 @@ +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" + +# Targeted flap capture (2026-06-08). Goal: pin what VARIES at the grazing +# doorway pose when the deep rooms flicker — especially the IDLE case. +# +# 1. [pv-input] : per frame — eye + player(RenderPos) + rawPlayer(physics) + yaw + flood. +# If the flood flickers, exactly one of those is the varying input. +# 2. CAPTURE_RESOLVE : full physics body JSONL per resolve (confirm body bit-stable at the doorway). +# 3. DUMP_CELLS : dump doorway cell geometry as fixtures so a deterministic +# eye-sweep builder test can be written without the live client. +Remove-Item Env:\ACDREAM_PROBE_FLAP -ErrorAction SilentlyContinue +$env:ACDREAM_PROBE_PVINPUT = "1" +$env:ACDREAM_CAPTURE_RESOLVE = "flap-doorway-resolve.jsonl" +$env:ACDREAM_DUMP_CELLS = "0xA9B40170,0xA9B40171,0xA9B40172,0xA9B40173,0xA9B40174,0xA9B40175,0xA9B40031" +$env:ACDREAM_DUMP_CELLS_DIR = "tests\AcDream.Core.Tests\Fixtures\flap-doorway" + +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "flap-doorway-pvinput.log" diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a0b7aa60..63f3cc48 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7569,8 +7569,14 @@ public sealed class GameWindow : IDisposable { var vp = envCellViewProj; char pvOutRoot = ReferenceEquals(clipRoot, _outdoorNode) ? 'Y' : 'n'; + // 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood); + // player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics + // body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood + // flickers while idle, exactly one of {eye, player, rawPlayer, yaw} is the varying input. + var pvRawPlayer = _playerController?.Position ?? playerViewPos; + float pvYaw = _playerController?.Yaw ?? 0f; Console.WriteLine(System.FormattableString.Invariant( - $"[pv-input] outRoot={pvOutRoot} flood={pvFrame.OrderedVisibleCells.Count} eye=({camPos.X:F6},{camPos.Y:F6},{camPos.Z:F6}) player=({playerViewPos.X:F6},{playerViewPos.Y:F6},{playerViewPos.Z:F6}) vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); + $"[pv-input] outRoot={pvOutRoot} flood={pvFrame.OrderedVisibleCells.Count} eye=({camPos.X:F6},{camPos.Y:F6},{camPos.Z:F6}) player=({playerViewPos.X:F6},{playerViewPos.Y:F6},{playerViewPos.Z:F6}) rawPlayer=({pvRawPlayer.X:F6},{pvRawPlayer.Y:F6},{pvRawPlayer.Z:F6}) yaw={pvYaw:F8} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); } sigPvFrame = pviewResult.PortalFrame; diff --git a/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40170.json b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40170.json new file mode 100644 index 00000000..346b1e55 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40170.json @@ -0,0 +1,413 @@ +{ + "CellId": 2847146352, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 7.45189 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1.2430552E-08, + "Z": 1 + }, + "D": 1.0701463E-07 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.54731, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -5.54731 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -8.609, + "Z": 0 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 2.5 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.45189, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 2.5 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.45189, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 7.45189 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1.2430552E-08, + "Z": 1 + }, + "D": 1.0701463E-07 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.54731, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -5.54731 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -8.609, + "Z": 0 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -0.99451864, + "Z": 0.10455982 + }, + "D": -8.5618105 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -8.609, + "Z": 0 + }, + { + "X": 7.45189, + "Y": -8.34616, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -8.34616, + "Z": 2.5 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 65535, + "PolygonId": 4, + "Flags": 5 + }, + { + "OtherCellId": 369, + "PolygonId": 5, + "Flags": 3 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146353, + 2847146354, + 2847146355, + 2847146356, + 2847146357 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40171.json b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40171.json new file mode 100644 index 00000000..48ea0445 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40171.json @@ -0,0 +1,3773 @@ +{ + "CellId": 2847146353, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 3, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 6, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 7, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 9, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 10, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 11, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + } + ] + }, + { + "Id": 12, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 13, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 14, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 15, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 16, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + } + ] + }, + { + "Id": 17, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 18, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 19, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 20, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 22, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 23, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 9.2 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 24, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 25, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 26, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 27, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 28, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + } + ] + }, + { + "Id": 29, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 30, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + } + ] + }, + { + "Id": 31, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 32, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 33, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 34, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 35, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 36, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 37, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 38, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 39, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 40, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 41, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 42, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 43, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 44, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 45, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 46, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 47, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 48, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -5.96045E-09, + "Y": -2.4835207E-09, + "Z": 1 + }, + "D": 4.6566015E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 49, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 50, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 51, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + } + ] + }, + { + "Id": 52, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 53, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 9.2 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 9, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 1.35 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 1.35 + } + ] + }, + { + "Id": 10, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.35, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 5.35, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 3.9 + }, + { + "X": 7.7, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 11, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + } + ] + }, + { + "Id": 12, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 13, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 14, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + } + ] + }, + { + "Id": 15, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 16, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 6 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 6 + } + ] + }, + { + "Id": 17, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 18, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 19, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 6 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 6 + } + ] + }, + { + "Id": 20, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + } + ] + }, + { + "Id": 22, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 23, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 24, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + } + ] + }, + { + "Id": 25, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + } + ] + }, + { + "Id": 26, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 4.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + } + ] + }, + { + "Id": 27, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 28, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 29, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 9, + "Y": -1.336, + "Z": 9 + }, + { + "X": 9, + "Y": -4.57, + "Z": 9 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 30, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 31, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 32, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -5.96045E-09, + "Y": -2.4835207E-09, + "Z": 1 + }, + "D": 4.6566015E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 33, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + } + ] + }, + { + "Id": 34, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 35, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -4.57, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 36, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 37, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -1.336, + "Z": 6 + }, + { + "X": 9, + "Y": -1.336, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + } + ] + }, + { + "Id": 38, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 39, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 40, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + }, + { + "X": 4.1, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 41, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": 1.15, + "Z": 8.52 + }, + { + "X": 7.4113, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 7.3 + }, + { + "X": 9, + "Y": 1.15, + "Z": 9.2 + } + ] + }, + { + "Id": 42, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 9, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 43, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 44, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.4113, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + }, + { + "X": 9, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 45, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.6728, + "Y": -7.65, + "Z": 8.52 + }, + { + "X": 5.6728, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 7.3 + }, + { + "X": 4.1, + "Y": -7.65, + "Z": 9.2 + } + ] + }, + { + "Id": 46, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.8, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 47, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 48, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + }, + { + "X": 7.8, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 49, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 7.65 + }, + "Vertices": [ + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 5.2, + "Y": -7.65, + "Z": 2.85 + } + ] + }, + { + "Id": 50, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -2.2, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 51, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 52, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + }, + { + "X": 4.1, + "Y": -2.2, + "Z": 2.85 + } + ] + }, + { + "Id": 53, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.4, + "Z": 2.85 + } + ] + }, + { + "Id": 54, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -7.65 + }, + "Vertices": [ + { + "X": 7.45189, + "Y": -7.65, + "Z": -1.19209E-08 + }, + { + "X": 7.45189, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": 2.5 + }, + { + "X": 5.54731, + "Y": -7.65, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 55, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + } + ] + }, + { + "Id": 56, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 5.96045E-09, + "Y": -1.387775E-17, + "Z": -1 + }, + "D": -5.364405E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 368, + "PolygonId": 54, + "Flags": 1 + }, + { + "OtherCellId": 371, + "PolygonId": 55, + "Flags": 1 + }, + { + "OtherCellId": 373, + "PolygonId": 56, + "Flags": 1 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146354, + 2847146355, + 2847146356, + 2847146357 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40172.json b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40172.json new file mode 100644 index 00000000..1be3dfce --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40172.json @@ -0,0 +1,1963 @@ +{ + "CellId": 2847146354, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.8289255, + "Y": 0, + "Z": -0.55935895 + }, + "D": 4.809128 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": -1.14552, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -0.65493053, + "Z": -0.7556891 + }, + "D": 5.214255 + }, + "Vertices": [ + { + "X": 1.94952, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": -1.14552, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0.63059264, + "Z": -0.776114 + }, + "D": 5.3551865 + }, + "Vertices": [ + { + "X": -1.14552, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": 1.94952, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 3, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.7999291, + "Y": 0, + "Z": -0.60009456 + }, + "D": 5.7001305 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + }, + { + "X": 1.94952, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": -1, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -1, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": -1, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 2, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 2, + "Y": 3, + "Z": 1 + }, + { + "X": -1, + "Y": 3, + "Z": 1 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 9, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": 2, + "Y": 3, + "Z": 2.9 + }, + { + "X": 2, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + } + ] + }, + { + "Id": 10, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": 2, + "Y": 3, + "Z": 2.9 + }, + { + "X": -1, + "Y": 3, + "Z": 2.9 + }, + { + "X": -1, + "Y": 3, + "Z": 1 + }, + { + "X": 2, + "Y": 3, + "Z": 1 + } + ] + }, + { + "Id": 11, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 3.9, + "Y": 0.052686, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 12, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 0.052686, + "Z": 2.9 + } + ] + }, + { + "Id": 13, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": 1.68, + "Y": -3.2, + "Z": 1 + }, + { + "X": 1.68, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 1 + } + ] + }, + { + "Id": 14, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + } + ] + }, + { + "Id": 15, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 1 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 1 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 16, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -1.05, + "Z": 1 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 17, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": 1.68, + "Y": -3.2, + "Z": 1 + }, + { + "X": -1, + "Y": -3.2, + "Z": 1 + }, + { + "X": -1, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 1.68, + "Y": -3.2, + "Z": 2.9 + } + ] + }, + { + "Id": 18, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -1.05, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 19, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 0.85, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 20, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 0.85, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 1 + } + ] + }, + { + "Id": 22, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 0.85, + "Z": 1 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 1 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 23, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -1.05, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 24, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 25, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 26, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -1, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -1, + "Y": -3.2, + "Z": 1 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 1 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.8289255, + "Y": 0, + "Z": -0.55935895 + }, + "D": 4.809128 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": -1.14552, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 1, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.7999291, + "Y": 0, + "Z": -0.60009456 + }, + "D": 5.7001305 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + }, + { + "X": 1.94952, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": 2, + "Y": 3, + "Z": 2.9 + }, + { + "X": -1, + "Y": 3, + "Z": 2.9 + }, + { + "X": -1, + "Y": 3, + "Z": 1 + }, + { + "X": 2, + "Y": 3, + "Z": 1 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": -1, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -1, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": 2, + "Y": 3, + "Z": 2.9 + }, + { + "X": 2, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 3 + }, + "Vertices": [ + { + "X": -1, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 2, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": 2, + "Y": 3, + "Z": 1 + }, + { + "X": -1, + "Y": 3, + "Z": 1 + } + ] + }, + { + "Id": 9, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + } + ] + }, + { + "Id": 10, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 3.9, + "Y": 0.052686, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 3, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 11, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": 0.052686, + "Z": 2.9 + } + ] + }, + { + "Id": 12, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 1 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 1 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 13, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": 1.68, + "Y": -3.2, + "Z": 1 + }, + { + "X": 1.68, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 1 + } + ] + }, + { + "Id": 14, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": 1.68, + "Y": -3.2, + "Z": 1 + }, + { + "X": -1, + "Y": -3.2, + "Z": 1 + }, + { + "X": -1, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": 1.68, + "Y": -3.2, + "Z": 2.9 + } + ] + }, + { + "Id": 15, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 3.2 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -1, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -1, + "Y": -3.2, + "Z": 1 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 1 + } + ] + }, + { + "Id": 16, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0.63059264, + "Z": -0.776114 + }, + "D": 5.3551865 + }, + "Vertices": [ + { + "X": -1.14552, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": 1.94952, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": 4.3 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 17, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -0.65493053, + "Z": -0.7556891 + }, + "D": 5.214255 + }, + "Vertices": [ + { + "X": 1.94952, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": -1.14552, + "Y": 2.38419E-08, + "Z": 6.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": 3.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 18, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 1.19209E-08 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": 3.9, + "Y": -3.2, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 19, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 0.85, + "Z": 1 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 1 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 20, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -1.05, + "Z": 1 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 3, + "Z": -1.19209E-08 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 1 + } + ] + }, + { + "Id": 22, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -3.2, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 23, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -1.05, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 24, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": -1.05, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + }, + { + "X": -2.9, + "Y": -3.2, + "Z": 4.3 + } + ] + }, + { + "Id": 25, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 0.85, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 4.3 + } + ] + }, + { + "Id": 26, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9000003 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 0.85, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 2.9 + }, + { + "X": -2.9, + "Y": 3, + "Z": 2.9 + } + ] + }, + { + "Id": 27, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + } + ] + }, + { + "Id": 28, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.9 + }, + "Vertices": [ + { + "X": -2.9, + "Y": 0.85, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 3.55 + }, + { + "X": -2.9, + "Y": -1.05, + "Z": 1 + }, + { + "X": -2.9, + "Y": 0.85, + "Z": 1 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 371, + "PolygonId": 27, + "Flags": 1 + }, + { + "OtherCellId": 367, + "PolygonId": 28, + "Flags": 3 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146353, + 2847146355, + 2847146356, + 2847146357 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40173.json b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40173.json new file mode 100644 index 00000000..aa6a9d84 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40173.json @@ -0,0 +1,413 @@ +{ + "CellId": 2847146355, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 5.9604556E-08, + "Y": 0, + "Z": 1 + }, + "D": -2.3245777E-07 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -7.54372E-07, + "Y": -1, + "Z": -6.0349706E-08 + }, + "D": 0.052689318 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 2.5 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -2.5033974E-05, + "Y": 1, + "Z": 2.002716E-06 + }, + "D": 1.8519901 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 2.5 + }, + "Vertices": [ + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -2.5033974E-05, + "Y": 1, + "Z": 2.002716E-06 + }, + "D": 1.8519901 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 5.9604556E-08, + "Y": 0, + "Z": 1 + }, + "D": -2.3245777E-07 + }, + "Vertices": [ + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -7.54372E-07, + "Y": -1, + "Z": -6.0349706E-08 + }, + "D": 0.052689318 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 4.1 + }, + "Vertices": [ + { + "X": 4.1, + "Y": 0.052686, + "Z": 2.5 + }, + { + "X": 4.1, + "Y": 0.0526863, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": -1.19209E-08 + }, + { + "X": 4.1, + "Y": -1.85189, + "Z": 2.5 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -3.9 + }, + "Vertices": [ + { + "X": 3.9, + "Y": -1.8519, + "Z": 2.5 + }, + { + "X": 3.9, + "Y": -1.85189, + "Z": 0 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 0 + }, + { + "X": 3.9, + "Y": 0.0526863, + "Z": 2.5 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 369, + "PolygonId": 4, + "Flags": 3 + }, + { + "OtherCellId": 370, + "PolygonId": 5, + "Flags": 3 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146353, + 2847146354, + 2847146356, + 2847146357 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40174.json b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40174.json new file mode 100644 index 00000000..a47a8941 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40174.json @@ -0,0 +1,583 @@ +{ + "CellId": 2847146356, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 3.999 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 2.98 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.664 + }, + "Vertices": [ + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 3.999 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 2.98 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": 2.664 + }, + "Vertices": [ + { + "X": -2.664, + "Y": 2.98, + "Z": -3.999 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -3.999 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 9, + "Y": 2.98, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 6, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": -0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": -2.664, + "Y": 2.98, + "Z": -0.364 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 373, + "PolygonId": 7, + "Flags": 1 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146353, + 2847146354, + 2847146355, + 2847146357 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40175.json b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40175.json new file mode 100644 index 00000000..49a53370 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B40175.json @@ -0,0 +1,413 @@ +{ + "CellId": 2847146357, + "WorldTransform": { + "M11": -1, + "M12": 8.74228E-08, + "M13": 0, + "M14": 0, + "M21": -8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": 0, + "M41": 161.929, + "M42": 7.50315, + "M43": 94, + "M44": 1 + }, + "InverseWorldTransform": { + "M11": -1, + "M12": -8.74228E-08, + "M13": 0, + "M14": -0, + "M21": 8.74228E-08, + "M22": -1, + "M23": 0, + "M24": 0, + "M31": 0, + "M32": 0, + "M33": 1, + "M34": -0, + "M41": 161.929, + "M42": 7.5031643, + "M43": -94, + "M44": 1 + }, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -7 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + } + ], + "PortalPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": 1.15 + }, + "Vertices": [ + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -7 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": 2.85 + }, + "Vertices": [ + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": 9 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 5.96045E-09, + "Y": -1.387775E-17, + "Z": -1 + }, + "D": -5.364405E-08 + }, + "Vertices": [ + { + "X": 9, + "Y": -2.85, + "Z": 1.11022E-16 + }, + { + "X": 7, + "Y": -2.85, + "Z": -1.19209E-08 + }, + { + "X": 7, + "Y": 1.15, + "Z": -1.19209E-08 + }, + { + "X": 9, + "Y": 1.15, + "Z": 0 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": 0.364 + }, + "Vertices": [ + { + "X": 7, + "Y": 1.15, + "Z": -0.364 + }, + { + "X": 7, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": -2.85, + "Z": -0.364 + }, + { + "X": 9, + "Y": 1.15, + "Z": -0.364 + } + ] + } + ], + "Portals": [ + { + "OtherCellId": 369, + "PolygonId": 4, + "Flags": 3 + }, + { + "OtherCellId": 372, + "PolygonId": 5, + "Flags": 3 + } + ], + "VisibleCellIds": [ + 2847146351, + 2847146352, + 2847146353, + 2847146354, + 2847146355, + 2847146356 + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index add3dae9..1dd41b37 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -34,6 +34,97 @@ public class PlayerMovementControllerTests Assert.Equal(96f, result.Position.Y, precision: 1); } + // ── Indoor-flap root cause: resting-body bit-stability ──────────────────── + // + // The indoor render "flap" (textures battling at the cottage doorway) is + // portal-flood membership instability. PortalVisibilityBuilder.Build is a + // proven-deterministic pure function, so the membership can only flip if its + // INPUT (the camera eye, derived from the player RenderPosition) varies. + // Live 6-dp capture (pvinput.log:54) shows the player RenderPosition carries + // a perpetual ~1-ULP flicker at rest (Z 94.000000 <-> 93.999992 — exactly one + // float mantissa step). ComputeRenderPosition is Vector3.Lerp(_prevPhysicsPos, + // _currPhysicsPos, alpha), and Lerp(a, a, t) == a exactly, so a jittering + // RenderPosition at rest means the physics body's resting Position is NOT + // bit-stable between ticks. Retail's authoritative local position is bit-stable + // at rest (validate_transition -> kill_velocity on every grounded contact), so + // retail never flaps. + // + // This test pins the physics-side invariant: a grounded body with no input + // must hold a byte-identical position across many frames. It PASSES — which + // is itself the evidence: the physics resting position is bit-stable, so the + // doorway flap is NOT a physics-rest jitter. See + // docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md + // (the flap is render-side portal-flood membership instability at the grazing + // doorway portal under a sweeping camera eye). Kept as a regression guard. + [Fact] + public void Update_AtRestNoInput_RenderPositionBitStableAcrossManyFrames() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + var rest = new Vector3(96f, 96f, 50f); + controller.SetPosition(rest, 0x0001); + + // Settle one frame so the resolver establishes its rest state, then + // capture the baseline the body must hold. + var settled = controller.Update(1f / 60f, new MovementInput()); + Vector3 baselineRender = settled.RenderPosition; + Vector3 baselinePhysics = settled.Position; + + // Hold still for ~10 s of 60 Hz frames (crosses MinQuantum every ~2 + // frames, so the 30 Hz physics tick fires throughout — same cadence as + // live). Any deviation, even one ULP, is the flap's root cause. + float maxRenderDev = 0f; + float maxPhysicsDev = 0f; + for (int i = 0; i < 600; i++) + { + var r = controller.Update(1f / 60f, new MovementInput()); + maxRenderDev = MathF.Max(maxRenderDev, (r.RenderPosition - baselineRender).Length()); + maxPhysicsDev = MathF.Max(maxPhysicsDev, (r.Position - baselinePhysics).Length()); + } + + Assert.True( + maxRenderDev == 0f && maxPhysicsDev == 0f, + $"resting body drifted: render={maxRenderDev * 1e6f:F3} µm, " + + $"physics={maxPhysicsDev * 1e6f:F3} µm; expected byte-identical rest"); + } + + // After walking then releasing input, the body must SETTLE to a + // byte-identical resting position — not keep blipping a residual velocity. + // This models the live flap: the player walks to the cottage doorway and + // stops, and the eye then carries a ~1-ULP jitter that flips portal-flood + // membership. Flat-terrain variant: if even this drifts, the residual-after- + // motion path is the root and it is not indoor-specific. + [Fact] + public void Update_WalkThenStop_SettlesToBitStableRest() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; + + // Walk forward ~0.5 s, then release. + for (int i = 0; i < 30; i++) + controller.Update(1f / 60f, new MovementInput(Forward: true)); + // Let velocity decay / state settle. + for (int i = 0; i < 30; i++) + controller.Update(1f / 60f, new MovementInput()); + + var settled = controller.Update(1f / 60f, new MovementInput()); + Vector3 basePos = settled.Position; + Vector3 baseRender = settled.RenderPosition; + + float maxPos = 0f, maxRender = 0f; + for (int i = 0; i < 600; i++) + { + var r = controller.Update(1f / 60f, new MovementInput()); + maxPos = MathF.Max(maxPos, (r.Position - basePos).Length()); + maxRender = MathF.Max(maxRender, (r.RenderPosition - baseRender).Length()); + } + + Assert.True(maxPos == 0f && maxRender == 0f, + $"post-walk rest drifted: pos={maxPos * 1e6f:F3} µm, render={maxRender * 1e6f:F3} µm"); + } + [Fact] public void Update_ForwardInput_MovesInFacingDirection() { diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs index 0054a823..04b73dbb 100644 --- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Numerics; +using AcDream.App.Input; using AcDream.Core.Physics; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -136,6 +137,137 @@ public class CellarUpTrajectoryReplayTests : IDisposable // Tests // ─────────────────────────────────────────────────────────────── + /// + /// Indoor-flap root cause (2026-06-08). A body resting on the cellar + /// floor with ZERO requested motion must hold a byte-identical position + /// across many ticks — retail's authoritative local position is bit-stable + /// at rest (validate_transition → kill_velocity + SetWalkable on every + /// grounded contact, decomp :272567/:274467). + /// + /// + /// The indoor render "flap" (textures battling at the cottage doorway) is + /// portal-flood membership instability. PortalVisibilityBuilder.Build is a + /// proven-deterministic pure function, so the membership can only flip if its + /// INPUT (the camera eye, from the player RenderPosition) varies. + /// RenderPosition = Lerp(_prevPhysicsPos, _currPhysicsPos), and Lerp(a,a,t)==a, + /// so a jittering eye at rest means the physics body's resting Position is not + /// bit-stable. Flat LandCell terrain rest IS bit-stable + /// (. + /// Update_AtRestNoInput_RenderPositionBitStableAcrossManyFrames passes); the + /// instability is the INDOOR path — the floor-touch is classified + /// walkable=False (no walkable-polygon anchor), so each tick re-fires a + /// step-down probe whose re-found Z is not bit-stable. + /// + /// + /// PASSES — the indoor resting body is bit-stable even with the + /// grounded/cp=none contradictory state present. This is evidence (with the + /// flat-terrain variant) that the doorway flap is NOT a physics-rest jitter; + /// it is render-side portal-flood membership instability under a sweeping eye. + /// See docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md. + /// The diagnostic log (on any future regression) names the failing per-tick + /// condition. Kept as a regression guard. + /// + [Fact] + public void IndoorCellarFloor_AtRestZeroOffset_BodyPositionBitStable() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + + // Body seeded exactly at its natural resting pose on the cellar floor, + // WITH the walkable-polygon + contact-plane anchor (BuildInitialBody) — + // i.e. the most-favourable starting state. If even this drifts, the rest + // path fails to PERSIST the anchor. + var body = BuildInitialBody(); + var rest = body.Position; + uint cell = CellarId; + bool grounded = true; + + var log = new List(); + float maxDrift = 0f; + for (int tick = 1; tick <= 200; tick++) + { + // ZERO requested motion: currentPos == targetPos == rest pose. + var result = engine.ResolveWithTransition( + currentPos: body.Position, + targetPos: body.Position, + cellId: cell, + sphereRadius: SphereRadius, + sphereHeight: SphereHeight, + stepUpHeight: StepUpHeight, + stepDownHeight: StepDownHeight, + isOnGround: grounded, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0); + + body.Position = result.Position; + cell = result.CellId; + grounded = result.IsOnGround; + + float drift = (body.Position - rest).Length(); + maxDrift = MathF.Max(maxDrift, drift); + + if (tick <= 6 || drift > 0f) + { + log.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "tick{0,3}: pos=({1:F7},{2:F7},{3:F7}) drift={4:F3}µm grounded={5} " + + "walkable={6} cpV={7} ts=0x{8:X}", + tick, body.Position.X, body.Position.Y, body.Position.Z, + drift * 1e6f, grounded, body.WalkablePolygonValid, + body.ContactPlaneValid, (uint)body.TransientState)); + } + } + + Assert.True(maxDrift == 0f, + $"cellar-floor rest drifted {maxDrift * 1e6f:F3} µm (expected byte-identical):\n " + + string.Join("\n ", log.Take(24))); + } + + /// + /// Indoor-flap investigation (2026-06-08) — the FULL production loop. Drives + /// (integration + flag logic + velocity, + /// not just the resolver) on the indoor cellar engine with NO input. PASSES — + /// the RenderPosition the camera reads is byte-identical at rest, confirming + /// the flap is not produced by the indoor controller rest loop. Kept as a + /// regression guard. See + /// docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md. + /// + [Fact] + public void IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(InitialSphereWorld, CellarId); + + var settled = controller.Update(1f / 60f, new MovementInput()); + var basePos = settled.Position; + var baseRender = settled.RenderPosition; + + var log = new List(); + float maxPos = 0f, maxRender = 0f; + for (int i = 1; i <= 600; i++) + { + var r = controller.Update(1f / 60f, new MovementInput()); + float dp = (r.Position - basePos).Length(); + float dr = (r.RenderPosition - baseRender).Length(); + maxPos = MathF.Max(maxPos, dp); + maxRender = MathF.Max(maxRender, dr); + if (i <= 4 || dp > 0f || dr > 0f) + { + log.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "f{0,3}: pos=({1:F7},{2:F7},{3:F7}) render=({4:F7},{5:F7},{6:F7}) " + + "grounded={7} cell=0x{8:X8}", + i, r.Position.X, r.Position.Y, r.Position.Z, + r.RenderPosition.X, r.RenderPosition.Y, r.RenderPosition.Z, + r.IsOnGround, r.CellId)); + } + } + + Assert.True(maxPos == 0f && maxRender == 0f, + $"indoor controller rest drifted: pos={maxPos * 1e6f:F3} µm, " + + $"render={maxRender * 1e6f:F3} µm (expected byte-identical):\n " + + string.Join("\n ", log.Take(24))); + } + /// /// Confirms the harness compiles, the engine runs the simulation, /// and a trajectory comes back with the expected number of points. From ab6ed905f1a07f165727e7d5ff637d66a3db1fca Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 12:33:06 +0200 Subject: [PATCH 046/172] =?UTF-8?q?docs(render):=20correct=20flap=20spec?= =?UTF-8?q?=20=E2=80=94=20enqueue-once=20REFUTED,=20bounded-propagation=20?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writing-plans decomp pass read FixCellList (433407) -> AdjustCellView (433741) -> ClipPortals(update_count) + AddViewToPortals, proving retail RE-PROCESSES a grown-after-drawn cell. So the approved "enqueue-once / no re-process" approach is wrong (it would break Build_ViewGrowthAfterDoneCell for the right reason — that test is actually retail-faithful). Corrected approach (user chose the faithful moderate port over an epsilon-dedup band-aid): KEEP re-processing on growth, but BOUND it the way retail does — each view slice processed once (monotonic update_count watermark) and redundant reciprocal back-contributions clip to EMPTY (OtherPortalClip -> no copy_view -> no new slice), so the reciprocal/drift loop can't churn. acdream churns because its reciprocal yields a drifted non-empty sliver, bounded only by the MaxReprocessPerCell=16 hack. Remove the cap; bound structurally. Scope unchanged: PortalVisibilityBuilder only; no rooting/camera/clip-math-rewrite/ seal change. One open precision (exact line where acdream's sliver becomes non-empty — float-drift-dependent on real geometry) deferred to the plan's first task: instrument PortalVisibilityBuilder (per-pop re-pop count + reciprocal-clip in/out + grew), capture at the doorway, pin it, THEN fix. Spec updated in place with a REVISION banner; superseded enqueue-once body retained for the audit trail. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...8-portal-flood-enqueue-once-port-design.md | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md b/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md index 4b7ca68e..6018d6da 100644 --- a/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md +++ b/docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md @@ -1,8 +1,48 @@ -# Portal-Flood Enqueue-Once Port — the indoor "flap" fix (verified design) +# Portal-Flood Bounded-Propagation Port — the indoor "flap" fix (verified design) + +**Date:** 2026-06-08 (revised — enqueue-once superseded) +**Branch:** `claude/thirsty-goldberg-51bb9b` +**Status:** Design revised after the writing-plans decomp pass; pending re-review. + +> ## ⚠️ REVISION (2026-06-08 PM): "enqueue-once" REFUTED — corrected to "bounded propagation" +> The original approach below (§4 enqueue-once, §5 correct-the-test) is **WRONG** and is retained only +> for the audit trail. The writing-plans decomp pass read `FixCellList` (decomp 433407) → +> `AdjustCellView` (433741) → `ClipPortals(update_count)` + `AddViewToPortals`, which proves **retail +> DOES re-process a grown-after-drawn cell**. So: +> - **Re-processing on growth is retail-faithful and STAYS.** Pure enqueue-once is wrong; it would break +> `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` for the *right* reason (the test is correct +> — do NOT "correct" it; §5 below is VOID). +> - **The real divergence is BOUNDING.** Retail's re-processing terminates *structurally* — each view +> slice is processed once (the `update_count` watermark is monotonic) and a redundant **reciprocal** +> back-contribution clips to **empty** (`OtherPortalClip` → no `copy_view` → no new slice; decomp +> 433654/433711-712). acdream's reciprocal (`ApplyReciprocalClip`+`ClipToRegion`) instead yields a +> **drifted non-empty sliver** → `grew` → re-enqueue → churn, bounded only by the +> `MaxReprocessPerCell=16` **hack**. The churn's fixpoint is eye-sensitive → the flap. +> - **Corrected fix:** port retail's **bounded propagation** — make redundant reciprocal/re-clip +> contributions NOT generate new propagatable slices (match retail's empty-reciprocal + monotonic +> `update_count` watermark), and remove the `MaxReprocessPerCell` cap. Keep re-processing. +> - **Scope:** still `PortalVisibilityBuilder` only; no rooting/camera/clip-math-rewrite/seal change. The +> user approved this corrected "faithful moderate port" direction (over a non-faithful epsilon-dedup +> band-aid) on 2026-06-08. +> - **One open precision (→ plan Task 1):** exactly *where* acdream's reciprocal sliver becomes non-empty +> is float-drift-dependent on real doorway geometry — a runtime fact. The implementation plan's first +> task **instruments `PortalVisibilityBuilder` (per-pop re-pop count + reciprocal-clip in/out + `grew`), +> captures at the doorway, and pins the exact line** before the fix, rather than guessing from decomp. +> - **Corrected retail grounding (the full traversal):** `ConstructView` 433750 (pop-once → draw-list → +> `ClipPortals(cell,0)` → `AddViewToPortals`); `ClipPortals` 433572 (slices `[update_count,view_count)`, +> `GetClip` per portal, exit→`copy_view`/OutsideView, neighbour→`OtherPortalClip`); `AddViewToPortals` +> 433446 (first-discovery→`InitCell`+`InsCellTodoList`; growth→`AddToCell`+`FixCellList`); +> `FixCellList` 433407 = `AdjustCellPlace` + `AdjustCellView` 433741 (=`ClipPortals(update_count)` + +> `AddViewToPortals` — **the re-process**); `OtherPortalClip` 433524 (reciprocal, empty-for-redundant). +> +> Everything below this banner is the ORIGINAL (superseded) enqueue-once design — kept for the record. +> --- + +# (SUPERSEDED) Portal-Flood Enqueue-Once Port — original design **Date:** 2026-06-08 **Branch:** `claude/thirsty-goldberg-51bb9b` -**Status:** Design approved (brainstorm). Pending implementation plan. +**Status:** SUPERSEDED by the revision banner above. > **Supersedes** the enqueue logic in `docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md` > (whose §4 enqueue-once was an *incomplete* attempt — it dropped the `AddToCell` growth half) and the From a3dadbf6649503f5256044d32f143468d96f1759 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 12:38:02 +0200 Subject: [PATCH 047/172] =?UTF-8?q?docs(render):=20implementation=20plan?= =?UTF-8?q?=20=E2=80=94=20portal-flood=20bounded-propagation=20(instrument?= =?UTF-8?q?+pin,=20then=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (fully specified): add the [portal-churn] probe (per-Build re-enqueue + reciprocal pre/post), a deterministic re-pop anchor test, and a live doorway capture to PIN the exact divergence (where acdream's redundant reciprocal back-contribution stays non-empty where retail clips to empty) — a float-drift runtime fact, not derivable from decomp. Phase 2 (evidence-gated outline): port the bound (redundant contributions don't add propagatable slices; remove MaxReprocessPerCell), keeping re-processing + Build_ViewGrowthAfterDoneCell green. Gets its own no-placeholder plan after the Phase 1 pin — apparatus-first, not a deferred placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-08-portal-flood-bounded-propagation.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md diff --git a/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md b/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md new file mode 100644 index 00000000..0baa1979 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md @@ -0,0 +1,187 @@ +# Portal-Flood Bounded-Propagation Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the indoor doorway "flap" by porting retail's *bounded* portal-flood propagation into `PortalVisibilityBuilder` — keeping re-processing on growth (retail-faithful) but stopping the reciprocal/drift churn that makes membership eye-sensitive. + +**Architecture:** The flap is `PortalVisibilityBuilder.Build`'s unbounded re-enqueue churn (`cs:322`, `MaxReprocessPerCell=16` hack): redundant reciprocal back-contributions yield drifted non-empty slivers → `grew` → re-enqueue, and the churn's fixpoint shifts under sub-cm eye motion. Retail bounds it structurally (monotonic `update_count` watermark + empty reciprocal). **Phase 1 instruments + pins the exact acdream divergence at the live doorway (it's float-drift-dependent — a runtime fact, not derivable from decomp). Phase 2 ports the bound, gated on Phase 1's evidence.** Spec: `docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION banner). + +**Tech Stack:** C# / .NET 10, xUnit. GL-free unit tests (`AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`). Live capture via `dotnet run` against local ACE. + +**Non-goals (unchanged from spec):** no rooting/camera/clip-math-rewrite/seal change; physics + the 4 rest-stability regression tests stay; `Build_ViewGrowthAfterDoneCell` stays GREEN (re-processing is kept). + +--- + +## PHASE 1 — Instrument & pin the exact divergence + +### Task 1: Add the portal-churn probe flag + +**Files:** +- Modify: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` (after `ProbePvInputEnabled`, ~line 144) +- Test: `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public void ProbePortalChurn_DefaultsFalse_WhenEnvUnset() +{ + // Env var is absent in the test host, so the flag must default false (inert probe). + Assert.False(AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled); +} +``` + +- [ ] **Step 2: Run it — expect FAIL** (compile error: `ProbePortalChurnEnabled` does not exist) + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ProbePortalChurn_DefaultsFalse"` +Expected: FAIL (does not compile). + +- [ ] **Step 3: Add the flag** (in `RenderingDiagnostics`, mirroring `ProbePvInputEnabled`) + +```csharp +/// +/// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits +/// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues, +/// max pop count, and — per re-enqueue — the reciprocal-clip pre→post region count + grew flag. Pins +/// whether the flap's churn is redundant reciprocal back-contributions producing non-empty drifted +/// slivers (the hypothesis) vs another source. Throwaway apparatus — strip once the bound ships. +/// Initial state from ACDREAM_PROBE_PORTAL_CHURN=1. +/// +public static bool ProbePortalChurnEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1"; +``` + +- [ ] **Step 4: Run it — expect PASS.** Run the same filter. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +git commit -m "feat(render-diag): add ACDREAM_PROBE_PORTAL_CHURN flag for the bounded-propagation pin" +``` + +### Task 2: Instrument `PortalVisibilityBuilder.Build` churn + reciprocal + +**Files:** +- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (the pop loop ~145-178; the reciprocal site ~295-306; the re-enqueue ~322; before `return frame` ~341) + +- [ ] **Step 1: Add a per-Build churn accumulator** (top of `Build`, after `var popCounts = ...` ~line 112) + +```csharp +// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn +// + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off. +bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled; +int churnReenqueues = 0; +var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null; +``` + +- [ ] **Step 2: Record reciprocal pre→post** (at the reciprocal site, right after `ApplyReciprocalClip(...)` ~line 297, before the `if (clippedRegion.Count == 0)` check) + +```csharp +if (churnProbe) + churnReciprocal!.Append(System.FormattableString.Invariant( + $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); +``` + +- [ ] **Step 3: Count re-enqueues** (inside the `if (grew && ... queued.Add(neighbourId))` block ~line 322, after `todo.Insert(neighbour, dist)`) + +```csharp +if (churnProbe) churnReenqueues++; +``` + +- [ ] **Step 4: Emit the summary** (just before `return frame;` ~line 341) + +```csharp +if (churnProbe) +{ + int maxPop = 0; uint maxCell = 0; int rePopped = 0; + foreach (var kv in popCounts) + { + if (kv.Value > maxPop) { maxPop = kv.Value; maxCell = kv.Key; } + if (kv.Value > 1) rePopped++; + } + Console.WriteLine(System.FormattableString.Invariant( + $"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} " + + $"reEnqueues={churnReenqueues} rePoppedCells={rePopped} maxPop=0x{maxCell:X8}:{maxPop}" + + churnReciprocal)); +} +``` + +- [ ] **Step 5: Build** — Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: `Build succeeded`. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +git commit -m "diag(render): [portal-churn] probe — per-Build re-enqueue + reciprocal pre/post" +``` + +### Task 3: Deterministic re-pop unit test (probe baseline) + +**Files:** +- Modify: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` + +- [ ] **Step 1: Write the test** — the `ViewGrowthAfterDoneCell` topology re-pops `B` (legitimate late growth from `D`). This proves the re-pop path is exercised deterministically (so the probe + later fix have a non-flaky anchor) without needing live float-drift. + +```csharp +[Fact] +public void Build_ViewGrowthAfterDoneCell_RePopsGrownCell() +{ + // Same A->B(near LEFT) + A->D(far RIGHT) + D->B(later) topology as + // Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit: B is popped via LEFT, then D grows B + // through RIGHT after B is done -> B re-pops. This is retail-faithful late growth (kept by the fix). + const uint A = 0x0001, B = 0x0002, D = 0x0003; + var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0), new CellPortalInfo((ushort)D, 1, 0, 0)); + a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); + a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0), + new CellPortalInfo(0xFFFF, 1, 0, 0), new CellPortalInfo((ushort)D, 2, 0, 0)); + b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var d = Cell(D, new CellPortalInfo((ushort)B, 0, 0, 2)); + d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var all = new Dictionary { [A] = a, [B] = b, [D] = d }; + + var frame = Build(a, all); + + // Membership (the flap-relevant output) is each cell once, regardless of re-pops. + Assert.Equal(new[] { B }, frame.OrderedVisibleCells.Where(c => c == B).ToArray()); + Assert.Contains(D, frame.OrderedVisibleCells); +} +``` + +- [ ] **Step 2: Run it — expect PASS** (documents current behavior; the re-pop happens internally, membership stays deduped). + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Build_ViewGrowthAfterDoneCell_RePopsGrownCell"` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +git commit -m "test(render): deterministic re-pop anchor for the bounded-propagation pin" +``` + +### Task 4: Live capture at the doorway + pin (CHECKPOINT — needs the user) + +- [ ] **Step 1: Launch with the churn probe** (add `$env:ACDREAM_PROBE_PORTAL_CHURN = "1"` to a copy of `launch-flap-capture.ps1`; keep `ACDREAM_PROBE_PVINPUT=1` for correlation). `dotnet build` green first, then launch in background, tee to `flap-churn.log`. +- [ ] **Step 2: User reproduces** — stand at the cottage doorway, turn the camera back and forth (the flap). ~15 s. +- [ ] **Step 3: Analyze** `flap-churn.log`: for the flap frames (correlate with `[pv-input]` flood flips), inspect `[portal-churn]`: which cells hit high `maxPop` (churn → near 16), and the `recip[...]` pre→post counts — is a redundant reciprocal back-contribution staying **non-empty** (`pre->post` both >0 on a cell that already contributed)? That is the predicted divergence. +- [ ] **Step 4: Pin + write the Phase 2 fix plan.** Record the pinned divergence (the exact cell/portal/condition where the redundant contribution stays non-empty) in a short findings note, and write `docs/superpowers/plans/2026-06-08-portal-flood-bound-fix.md` (Phase 2) with the exact, evidence-grounded code change. **Do not write Phase 2 code before this pin.** + +--- + +## PHASE 2 — Port the bound (outline; finalized from Phase 1's pin) + +**Shape (locked; exact predicate from Task 4):** make redundant reciprocal / re-clip contributions **not** generate a new propagatable slice — matching retail's empty-reciprocal (`OtherPortalClip`→no `copy_view`) + monotonic `update_count` watermark — then **remove `MaxReprocessPerCell` + `popCounts`** (termination becomes structural). Keep re-processing on growth (the `AddRegion` union + the deferred re-process), so `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` stays GREEN. Most-likely concrete form (confirm in Task 4): gate the `grew → re-enqueue` so a back-contribution whose clipped region is already covered by the cell's accumulated view (retail: empty after reciprocal clip) does not count as growth — via the faithful reciprocal/watermark, NOT an epsilon dedup heuristic. + +**Tests:** the new eye-sweep stability test (membership a single contiguous run as the eye sweeps — synthetic grazing topology if reproducible, else the live `[pv-input]` gate); all existing `PortalVisibilityBuilderTests` green incl. `Build_ViewGrowthAfterDoneCell_*`, `Build_IsDeterministic_*`, `Builder_CyclicGraph/Hub` termination; the 4 physics rest-stability guards green. + +**Acceptance (visual gate — the real one):** at the cottage doorway, turn the camera back and forth and walk through — interior rooms render steadily, no battling/popping; `[pv-input]` flood stable per eye pose; `[portal-churn]` `maxPop` ≤ small constant (no near-16 churn). Then strip the apparatus (`[portal-churn]`, `[pv-input]`, the launch scripts). + +--- + +## Self-Review notes +- Spec coverage: Phase 1 implements the spec's "instrument + pin first" requirement; Phase 2 implements the bounded-propagation fix. `Build_ViewGrowthAfterDoneCell` explicitly kept green (spec correction). No rooting/camera/clip/seal change. +- Phase 2 is intentionally an outline: its exact predicate is the runtime fact Phase 1 pins (the spec + this plan both flag this). Phase 2 gets its own no-placeholder plan after Task 4 — this is the apparatus-first discipline, not a deferred placeholder. From 687040ba52198f8d2ab98fc030a2ffa01c8458b4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 12:49:17 +0200 Subject: [PATCH 048/172] feat(render-diag): add ACDREAM_PROBE_PORTAL_CHURN flag for the bounded-propagation pin Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Rendering/RenderingDiagnostics.cs | 11 +++++++++++ .../Rendering/RenderingDiagnosticsTests.cs | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 03621062..33ad9c97 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -143,6 +143,17 @@ public static class RenderingDiagnostics public static bool ProbePvInputEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PVINPUT") == "1"; + /// + /// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits + /// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues, + /// max pop count, and — per re-enqueue — the reciprocal-clip pre→post region count + grew flag. Pins + /// whether the flap's churn is redundant reciprocal back-contributions producing non-empty drifted + /// slivers (the hypothesis) vs another source. Throwaway apparatus — strip once the bound ships. + /// Initial state from ACDREAM_PROBE_PORTAL_CHURN=1. + /// + public static bool ProbePortalChurnEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1"; + // Cell-change gate for EmitVis. The probe fires once per distinct root cell // so launch.log stays readable under motion (the per-frame call is a no-op // when the root is unchanged). Sentinel 0 = "no root yet" — the first real diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index fc6e0708..86ff7ac8 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -132,4 +132,11 @@ public sealed class RenderingDiagnosticsTests // playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render). Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, renderRootResolved: true)); } + + [Fact] + public void ProbePortalChurn_DefaultsFalse_WhenEnvUnset() + { + // Env var is absent in the test host, so the flag must default false (inert probe). + Assert.False(AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled); + } } From e6fe4c611ae54a90600c6e38f8cdca96c25b5b6d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 12:51:48 +0200 Subject: [PATCH 049/172] =?UTF-8?q?diag(render):=20[portal-churn]=20probe?= =?UTF-8?q?=20=E2=80=94=20per-Build=20re-enqueue=20+=20reciprocal=20pre/po?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 summary-emit adapted from the plan: the plan's Invariant($"a" + $"b" + sb) form passes a string to FormattableString.Invariant (which requires a FormattableString) and does not compile; merged the two interpolated fragments into one literal and appended the already-invariant-formatted reciprocal detail outside the Invariant call. Same output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/PortalVisibilityBuilder.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index e421e3a4..52e5c364 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -112,6 +112,12 @@ public static class PortalVisibilityBuilder var popCounts = new Dictionary(); // per-cell pop count for the MaxReprocessPerCell cap var trace = PortalBuildTrace.Start(cameraCell, cameraPos); + // [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn + // + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off. + bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled; + int churnReenqueues = 0; + var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null; + bool pvDump = false; if (s_pvDump) { @@ -295,6 +301,9 @@ public static class PortalVisibilityBuilder var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; int preReciprocalCount = clippedRegion.Count; ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj); + if (churnProbe) + churnReciprocal!.Append(System.FormattableString.Invariant( + $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); if (clippedRegion.Count == 0) { if (preReciprocalClip is null) @@ -324,6 +333,7 @@ public static class PortalVisibilityBuilder dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); todo.Insert(neighbour, dist); inserted = true; + if (churnProbe) churnReenqueues++; } trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}"); } @@ -338,6 +348,18 @@ public static class PortalVisibilityBuilder EmitFlapProbe(cameraCell, cameraPos, viewProj, frame); trace?.Emit(frame); + if (churnProbe) + { + int maxPop = 0; uint maxCell = 0; int rePopped = 0; + foreach (var kv in popCounts) + { + if (kv.Value > maxPop) { maxPop = kv.Value; maxCell = kv.Key; } + if (kv.Value > 1) rePopped++; + } + Console.WriteLine(System.FormattableString.Invariant( + $"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} reEnqueues={churnReenqueues} rePoppedCells={rePopped} maxPop=0x{maxCell:X8}:{maxPop}") + churnReciprocal); + } + return frame; } From a866c510e3e6020e84753ef2a6009f992679a26d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 12:52:46 +0200 Subject: [PATCH 050/172] test(render): deterministic re-pop anchor for the bounded-propagation pin Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/PortalVisibilityBuilderTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 38818c92..111b34ba 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -185,6 +185,32 @@ public class PortalVisibilityBuilderTests Assert.False(frame.OutsideView.IsEmpty); } + [Fact] + public void Build_ViewGrowthAfterDoneCell_RePopsGrownCell() + { + // Same A->B(near LEFT) + A->D(far RIGHT) + D->B(later) topology as + // Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit: B is popped via LEFT, then D grows B + // through RIGHT after B is done -> B re-pops. This is retail-faithful late growth (kept by the fix). + const uint A = 0x0001, B = 0x0002, D = 0x0003; + var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0), new CellPortalInfo((ushort)D, 1, 0, 0)); + a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); + a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0), + new CellPortalInfo(0xFFFF, 1, 0, 0), new CellPortalInfo((ushort)D, 2, 0, 0)); + b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var d = Cell(D, new CellPortalInfo((ushort)B, 0, 0, 2)); + d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); + var all = new Dictionary { [A] = a, [B] = b, [D] = d }; + + var frame = Build(a, all); + + // Membership (the flap-relevant output) is each cell once, regardless of re-pops. + Assert.Equal(new[] { B }, frame.OrderedVisibleCells.Where(c => c == B).ToArray()); + Assert.Contains(D, frame.OrderedVisibleCells); + } + [Fact] public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() { From b3a9884dff2ab07e941ea30048a93f60884c0920 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 12:56:44 +0200 Subject: [PATCH 051/172] =?UTF-8?q?diag(render):=20launch-flap-churn.ps1?= =?UTF-8?q?=20=E2=80=94=20Phase=201=20portal-churn=20pin=20capture=20scrip?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- launch-flap-churn.ps1 | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 launch-flap-churn.ps1 diff --git a/launch-flap-churn.ps1 b/launch-flap-churn.ps1 new file mode 100644 index 00000000..e979cbbd --- /dev/null +++ b/launch-flap-churn.ps1 @@ -0,0 +1,23 @@ +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" + +# Phase 1 portal-flood bounded-propagation PIN (2026-06-08). +# Goal: on the doorway flap frames, show whether PortalVisibilityBuilder.Build's churn is +# driven by redundant reciprocal back-contributions staying NON-EMPTY (the hypothesis). +# +# 1. [pv-input] : per indoor-Build frame — outRoot + flood count + eye/player/yaw. The +# flood-flip frames (e.g. 8<->3) are the flap; correlate with churn below. +# 2. [portal-churn]: per Build call — cells, reEnqueues, rePoppedCells, maxPop=0x:, +# then per reciprocal call: recip[0x pre->post]. A back-contribution +# that stays pre>0 AND post>0 on a cell that already contributed is the pin. +# FLAP probe is intentionally OFF (its per-portal [flap] dump would drown the log). +Remove-Item Env:\ACDREAM_PROBE_FLAP -ErrorAction SilentlyContinue +$env:ACDREAM_PROBE_PVINPUT = "1" +$env:ACDREAM_PROBE_PORTAL_CHURN = "1" + +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "flap-churn.log" From cd974b29bccf27de2a66e898195fb1ee8c0f0325 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 14:59:59 +0200 Subject: [PATCH 052/172] =?UTF-8?q?fix(camera):=20rest-snap=20render=20pos?= =?UTF-8?q?ition=20=E2=80=94=20kills=20the=20indoor=20doorway=20standing-s?= =?UTF-8?q?till=20flicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (pinned live, flap-churn.log at the Holtburg cottage doorway): the physics body is byte-stable at rest (rawPlayer = 1 distinct value), but PlayerMovementController.ComputeRenderPosition's Lerp(prev, curr, alpha) dithers the render position by microns — the two physics-tick snapshots lag the settled body (per-frame resolve edge-settles the resting sphere against the doorframe after the last tick wrote curr) while the leftover-accumulator alpha varies every frame. The grazing- doorframe camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that ~1000x into a ~1.3 mm eye jitter (eye 17 distinct, RenderPosition 15 distinct) that trips the PortalVisibilityBuilder clip -> the standing-still flicker (blue void / grass over the cellar entrance) the user reported. Fix: at rest (body velocity below RestVelocityEpsilonSq) render AT the authoritative byte-stable body position instead of interpolating between two stale tick snapshots, so the camera's pivot input is byte-stable and the sweep output stops jittering. Mirrors retail (a resting object renders bit-stable) + the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, d2212cf), one layer earlier. Sub-tick interpolation is preserved during motion (velocity above epsilon). This SUPERSEDES the committed bounded-propagation plan: the live pin proved ZERO portal re-enqueue churn during the flap (maxPop=1 across 13k oscillating frames; 0/63k reciprocals ever clipped empty), so the flap was never the churn the spec hypothesized. The ACDREAM_PROBE_PORTAL_CHURN apparatus did its job (refuted the hypothesis before the wrong fix was built); plan/spec/memory updates to follow. TDD: extracted the rest-snap into an internal-static pure ComputeRenderPosition; RED rest- snap test (stale prev!=curr + varying alpha dithers) -> GREEN after the gate; motion test guards interpolation; precondition test confirms a settled body's velocity is below the gate threshold. 29 controller+cellar + 62 camera+portal tests green, no regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Input/PlayerMovementController.cs | 25 ++++++- .../Input/PlayerMovementControllerTests.cs | 67 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 5332fe37..d422738f 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -810,7 +810,30 @@ public sealed class PlayerMovementController private Vector3 ComputeRenderPosition() { float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); - return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); + return ComputeRenderPosition(_prevPhysicsPos, _currPhysicsPos, _body.Position, _body.Velocity, alpha); + } + + // Render-position rest-snap (2026-06-08, indoor doorway flap). At rest the authoritative + // body position is byte-stable, but the two physics-tick snapshots (prev/curr) can lag it by + // microns — the per-frame resolve edge-settles the resting sphere against doorframe geometry + // after the last tick wrote curr — so Lerp(prev, curr, alpha) with a per-frame-VARYING + // leftover-accumulator alpha dithers the render position by microns. The grazing-doorframe + // camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that ~1000x into a + // ~1.3 mm eye jitter that trips the portal-flood clip → the standing-still indoor flicker + // (pinned live, flap-churn.log: rawPlayer 1 distinct, RenderPosition 15 distinct, eye 17). + // When the body is at rest (velocity below epsilon) render AT the authoritative position so + // the camera's pivot input is byte-stable. Mirrors retail (a resting object renders + // bit-stable) + the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, d2212cf), + // one layer earlier. Interpolation between tick snapshots is preserved during motion + // (velocity above epsilon), so sub-tick movement stays smooth. + internal const float RestVelocityEpsilonSq = 1e-4f; // (0.01 m/s)^2 — below this the body is at rest + + internal static Vector3 ComputeRenderPosition( + Vector3 prevPhysicsPos, Vector3 currPhysicsPos, Vector3 bodyPosition, Vector3 bodyVelocity, float alpha) + { + if (bodyVelocity.LengthSquared() < RestVelocityEpsilonSq) + return bodyPosition; // at rest: render at the authoritative byte-stable position + return Vector3.Lerp(prevPhysicsPos, currPhysicsPos, alpha); } public MovementResult Update(float dt, MovementInput input) diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index 1dd41b37..260fd73a 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -224,6 +224,73 @@ public class PlayerMovementControllerTests Assert.Equal(result.Position.Z, result.RenderPosition.Z, precision: 4); } + // ── Indoor doorway flap: render-position rest-snap (2026-06-08) ─────────── + // + // Live pin (flap-churn.log, user at the cottage doorway): the physics body is + // byte-stable at rest (rawPlayer = 1 distinct value), but the render position + // (Lerp of the two physics-tick snapshots) jitters ~µm and the camera EYE + // jitters ~1.3 mm — a ~1000x amplification by the grazing-doorframe camera- + // collision sweep, which trips the portal clip → the standing-still flicker. + // The dither is structural: at rest the tick snapshots (_prevPhysicsPos / + // _currPhysicsPos) can lag the settled authoritative Position by microns (door- + // frame edge-settle in the per-frame resolve), so Lerp(prev, curr, alpha) with a + // per-frame-VARYING alpha sweeps a tiny segment instead of holding still. Fix: + // at rest (velocity below epsilon) render AT the authoritative body position — + // byte-identical and alpha-independent. Mirrors retail (a resting object renders + // bit-stable) and the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, + // d2212cf), one layer earlier so the camera's pivot input is byte-stable too. + // + // The flat-terrain controller tests above CANNOT reproduce the doorframe-specific + // prev!=curr-at-rest condition (flat terrain collapses prev==curr), so these test + // the pure rest-snap function directly; the end-to-end acceptance is the live + // doorway visual gate. + [Fact] + public void ComputeRenderPosition_AtRestWithStaleEndpoints_SnapsToAuthoritativePosition_NoAlphaDither() + { + // prev lags curr by 30 µm (the live doorframe edge-settle lag); body = the settled + // authoritative position; velocity = 0 (at rest). Two different leftover-accumulator + // alphas must BOTH return the authoritative position, byte-identical (no dither). + var prev = new Vector3(155.525116f, 14.225600f, 94f); + var curr = new Vector3(155.525146f, 14.225600f, 94f); + var body = new Vector3(155.525146f, 14.225600f, 94f); + + var lowAlpha = PlayerMovementController.ComputeRenderPosition(prev, curr, body, Vector3.Zero, alpha: 0.15f); + var highAlpha = PlayerMovementController.ComputeRenderPosition(prev, curr, body, Vector3.Zero, alpha: 0.93f); + + Assert.Equal(body, lowAlpha); // byte-identical to the authoritative position + Assert.Equal(lowAlpha, highAlpha); // alpha-independent at rest (no dither) + } + + [Fact] + public void ComputeRenderPosition_Moving_InterpolatesBetweenTickSnapshots() + { + // Guard the no-over-fire half: while moving (velocity well above the rest epsilon) + // the render position must still interpolate smoothly between the tick snapshots. + var prev = new Vector3(96.0f, 96f, 50f); + var curr = new Vector3(96.3f, 96f, 50f); + var moving = new Vector3(3.12f, 0f, 0f); // walk speed + + var half = PlayerMovementController.ComputeRenderPosition(prev, curr, curr, moving, alpha: 0.5f); + + Assert.Equal(96.15f, half.X, precision: 3); // midpoint — interpolation preserved + } + + [Fact] + public void Update_AtRest_BodyVelocityBelowRenderRestSnapThreshold() + { + // Precondition for the render-position rest-snap: a settled grounded body's velocity must + // be below RestVelocityEpsilonSq, else ComputeRenderPosition's gate never fires at rest and + // the doorway flicker persists. kill_velocity on grounded contact drives it to zero. + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + for (int i = 0; i < 60; i++) controller.Update(1f / 60f, new MovementInput()); + + Assert.True( + controller.BodyVelocity.LengthSquared() < PlayerMovementController.RestVelocityEpsilonSq, + $"resting body velocity {controller.BodyVelocity.Length()} m/s must be below the rest-snap threshold"); + } + [Fact] public void Update_RunForward_MoveFasterThanWalk() { From 9b1857ac52849f6c8fae8a4bd524b0fae9d96e7e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 15:08:25 +0200 Subject: [PATCH 053/172] =?UTF-8?q?Revert=20"fix(camera):=20rest-snap=20re?= =?UTF-8?q?nder=20position=20=E2=80=94=20kills=20the=20indoor=20doorway=20?= =?UTF-8?q?standing-still=20flicker"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit cd974b29bccf27de2a66e898195fb1ee8c0f0325. --- .../Input/PlayerMovementController.cs | 25 +------ .../Input/PlayerMovementControllerTests.cs | 67 ------------------- 2 files changed, 1 insertion(+), 91 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index d422738f..5332fe37 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -810,30 +810,7 @@ public sealed class PlayerMovementController private Vector3 ComputeRenderPosition() { float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); - return ComputeRenderPosition(_prevPhysicsPos, _currPhysicsPos, _body.Position, _body.Velocity, alpha); - } - - // Render-position rest-snap (2026-06-08, indoor doorway flap). At rest the authoritative - // body position is byte-stable, but the two physics-tick snapshots (prev/curr) can lag it by - // microns — the per-frame resolve edge-settles the resting sphere against doorframe geometry - // after the last tick wrote curr — so Lerp(prev, curr, alpha) with a per-frame-VARYING - // leftover-accumulator alpha dithers the render position by microns. The grazing-doorframe - // camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that ~1000x into a - // ~1.3 mm eye jitter that trips the portal-flood clip → the standing-still indoor flicker - // (pinned live, flap-churn.log: rawPlayer 1 distinct, RenderPosition 15 distinct, eye 17). - // When the body is at rest (velocity below epsilon) render AT the authoritative position so - // the camera's pivot input is byte-stable. Mirrors retail (a resting object renders - // bit-stable) + the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, d2212cf), - // one layer earlier. Interpolation between tick snapshots is preserved during motion - // (velocity above epsilon), so sub-tick movement stays smooth. - internal const float RestVelocityEpsilonSq = 1e-4f; // (0.01 m/s)^2 — below this the body is at rest - - internal static Vector3 ComputeRenderPosition( - Vector3 prevPhysicsPos, Vector3 currPhysicsPos, Vector3 bodyPosition, Vector3 bodyVelocity, float alpha) - { - if (bodyVelocity.LengthSquared() < RestVelocityEpsilonSq) - return bodyPosition; // at rest: render at the authoritative byte-stable position - return Vector3.Lerp(prevPhysicsPos, currPhysicsPos, alpha); + return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } public MovementResult Update(float dt, MovementInput input) diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index 260fd73a..1dd41b37 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -224,73 +224,6 @@ public class PlayerMovementControllerTests Assert.Equal(result.Position.Z, result.RenderPosition.Z, precision: 4); } - // ── Indoor doorway flap: render-position rest-snap (2026-06-08) ─────────── - // - // Live pin (flap-churn.log, user at the cottage doorway): the physics body is - // byte-stable at rest (rawPlayer = 1 distinct value), but the render position - // (Lerp of the two physics-tick snapshots) jitters ~µm and the camera EYE - // jitters ~1.3 mm — a ~1000x amplification by the grazing-doorframe camera- - // collision sweep, which trips the portal clip → the standing-still flicker. - // The dither is structural: at rest the tick snapshots (_prevPhysicsPos / - // _currPhysicsPos) can lag the settled authoritative Position by microns (door- - // frame edge-settle in the per-frame resolve), so Lerp(prev, curr, alpha) with a - // per-frame-VARYING alpha sweeps a tiny segment instead of holding still. Fix: - // at rest (velocity below epsilon) render AT the authoritative body position — - // byte-identical and alpha-independent. Mirrors retail (a resting object renders - // bit-stable) and the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, - // d2212cf), one layer earlier so the camera's pivot input is byte-stable too. - // - // The flat-terrain controller tests above CANNOT reproduce the doorframe-specific - // prev!=curr-at-rest condition (flat terrain collapses prev==curr), so these test - // the pure rest-snap function directly; the end-to-end acceptance is the live - // doorway visual gate. - [Fact] - public void ComputeRenderPosition_AtRestWithStaleEndpoints_SnapsToAuthoritativePosition_NoAlphaDither() - { - // prev lags curr by 30 µm (the live doorframe edge-settle lag); body = the settled - // authoritative position; velocity = 0 (at rest). Two different leftover-accumulator - // alphas must BOTH return the authoritative position, byte-identical (no dither). - var prev = new Vector3(155.525116f, 14.225600f, 94f); - var curr = new Vector3(155.525146f, 14.225600f, 94f); - var body = new Vector3(155.525146f, 14.225600f, 94f); - - var lowAlpha = PlayerMovementController.ComputeRenderPosition(prev, curr, body, Vector3.Zero, alpha: 0.15f); - var highAlpha = PlayerMovementController.ComputeRenderPosition(prev, curr, body, Vector3.Zero, alpha: 0.93f); - - Assert.Equal(body, lowAlpha); // byte-identical to the authoritative position - Assert.Equal(lowAlpha, highAlpha); // alpha-independent at rest (no dither) - } - - [Fact] - public void ComputeRenderPosition_Moving_InterpolatesBetweenTickSnapshots() - { - // Guard the no-over-fire half: while moving (velocity well above the rest epsilon) - // the render position must still interpolate smoothly between the tick snapshots. - var prev = new Vector3(96.0f, 96f, 50f); - var curr = new Vector3(96.3f, 96f, 50f); - var moving = new Vector3(3.12f, 0f, 0f); // walk speed - - var half = PlayerMovementController.ComputeRenderPosition(prev, curr, curr, moving, alpha: 0.5f); - - Assert.Equal(96.15f, half.X, precision: 3); // midpoint — interpolation preserved - } - - [Fact] - public void Update_AtRest_BodyVelocityBelowRenderRestSnapThreshold() - { - // Precondition for the render-position rest-snap: a settled grounded body's velocity must - // be below RestVelocityEpsilonSq, else ComputeRenderPosition's gate never fires at rest and - // the doorway flicker persists. kill_velocity on grounded contact drives it to zero. - var engine = MakeFlatEngine(); - var controller = new PlayerMovementController(engine); - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); - for (int i = 0; i < 60; i++) controller.Update(1f / 60f, new MovementInput()); - - Assert.True( - controller.BodyVelocity.LengthSquared() < PlayerMovementController.RestVelocityEpsilonSq, - $"resting body velocity {controller.BodyVelocity.Length()} m/s must be below the rest-snap threshold"); - } - [Fact] public void Update_RunForward_MoveFasterThanWalk() { From fe87e9794a4dd428394cd3e04e13ab6462158807 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 16:19:34 +0200 Subject: [PATCH 054/172] =?UTF-8?q?docs(render):=20FLAP=20settled=20by=20l?= =?UTF-8?q?ive-retail=20measurement=20=E2=80=94=20full=20retail=20port=20D?= =?UTF-8?q?ECIDED=20(Option=20A)=20+=20exhaustive=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attached cdb to the live 2013 retail client at the Holtburg doorway + read the decomp. The indoor flap is a STRUCTURAL divergence, settled by measurement (not inference): - Retail has ONE render path: DrawInside(viewer_cell) every frame. NO inside/outside branch (RenderNormalMode's outside branch is dead code; is_player_outside only gates sky/lighting). "Entering a building" is not a render event — only the camera sweep resolving a different viewer_cell. Same path before/after threshold -> no seam. - Retail's eye JITTERS ~36um at rest yet membership is stable -> robustness is STRUCTURAL: many small per-building floods (~7/frame, ~2 cells each, via terrain BSP -> DrawPortal -> ConstructView(CBldPortal)), not one giant knife-edge flood. - Our 3 divergences: (D1) invented inside/outside branch (GameWindow.cs:7498, clipRoot = viewerRoot ?? _outdoorNode :7396); (D2) synthetic _outdoorNode; (D3) one unified flood. DECISION (user-approved): Option A — rip out branch + outdoor node, root always at the real viewer_cell, one DrawInside, per-building rendering. Phased, conformance-tested, visual-gated. REFUTED by measurement (do not retry): bounded-propagation/churn (maxPop=1, 0/63k reciprocals empty); byte-stable eye (retail's jitters ~36um — rest-snap cd974b2 failed + regressed, reverted 9b1857a). Lands the canonical exhaustive handoff for a FRESH session (docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md), the CLAUDE.md READ-THIS-FIRST banner, and reusable cdb apparatus. No project code changed; working tree at the known-good baseline. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 22 + ...ull-retail-render-port-OPTION-A-handoff.md | 545 ++++++++++++++++++ launch-flap-verify.ps1 | 17 + tools/cdb/flap-eye-stability.cdb | 15 + tools/cdb/flap-pos-lookup.cdb | 11 + tools/cdb/flap-render-capture.cdb | 19 + 6 files changed, 629 insertions(+) create mode 100644 docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md create mode 100644 launch-flap-verify.ps1 create mode 100644 tools/cdb/flap-eye-stability.cdb create mode 100644 tools/cdb/flap-pos-lookup.cdb create mode 100644 tools/cdb/flap-render-capture.cdb diff --git a/CLAUDE.md b/CLAUDE.md index 9a72d3b8..1c3c2e9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -763,6 +763,28 @@ H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven. **Currently working toward: M1.5 — Indoor world feels right** (resumed from 2026-05-20 baseline after Phase O ship). +**2026-06-08 (evening) — FLAP ROOT CAUSE SETTLED BY LIVE-RETAIL MEASUREMENT; full retail render +port DECIDED (Option A). READ THIS FIRST — it supersedes EVERY flap banner below, including the +bounded-propagation/churn direction (REFUTED by measurement: `maxPop=1`, 0 churn).** We attached cdb +to the **live 2013 retail client** at the Holtburg doorway + read the decomp. Findings (measured, not +inferred): **retail has ONE render path — `DrawInside(viewer_cell)` every frame, NO inside/outside +branch** (`RenderNormalMode`'s outside branch is dead code; `is_player_outside` only gates +sky/lighting). "Entering a building" is NOT a render event — only the camera sweep resolving a +different `viewer_cell` (outdoor `CLandCell` → indoor `CEnvCell`); same path before/after the +threshold → no seam → no flap. **Retail's eye JITTERS ~36 µm at rest** (so a byte-stable eye is the +WRONG target — my render-position rest-snap fix `cd974b2` failed + regressed, reverted `9b1857a`); +retail's membership is stable anyway because it does **many small per-building floods** (~7/frame, +~2 cells each, via the terrain BSP → `DrawPortal` → `ConstructView(CBldPortal)`), not one giant +unified flood. **Our 3 divergences:** (D1) we invented an inside/outside branch +(`GameWindow.cs:7498`, `clipRoot = viewerRoot ?? _outdoorNode` :7396); (D2) a synthetic `_outdoorNode`; +(D3) one unified flood. **Decision (user-approved): Option A — rip out the branch + outdoor node, root +always at the real `viewer_cell`, one `DrawInside`, per-building rendering.** DO NOT retry: byte-stable +eye, bounded-propagation/churn, physics rest-jitter, viewer-cell dead-zone, two-pipe split (all +evidence-disproven). **CANONICAL PICKUP (exhaustive — read top-to-bottom before any code):** +[`docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md`](docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md). +Close its §8 open traces (viewer_sought_position write site; ClipPortals/AddViewToPortals; how +`DrawInside` handles an outdoor `CLandCell` root) BEFORE writing the implementation plan. + **2026-06-05 (PM) — Indoor FLICKER + bluish VOID ROOT CAUSE CONFIRMED (decomp + live cdb); 3-part retail-faithful fix PLANNED (READ THIS FIRST).** The "core inside render / cellar floor drops" framing below is **SUPERSEDED** by this session's diagnosis. R1's per-cell `DrawInside` is already built and the cottage/cellar **seals** (user visual-verified). The diff --git a/docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md b/docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md new file mode 100644 index 00000000..4ab224eb --- /dev/null +++ b/docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md @@ -0,0 +1,545 @@ +# HANDOFF — Full Retail Render Port (Option A): one `DrawInside(viewer_cell)` path, no inside/outside branch + +**Date:** 2026-06-08 (evening) +**Branch:** `claude/thirsty-goldberg-51bb9b` (HEAD `9b1857a`) +**Status:** Design DECIDED (Option A). No implementation started. This is the canonical pickup +document for a FRESH session. Read it top-to-bottom before touching code. +**Author's note to the next session:** this is the payoff of a ~4-week saga + one long +measurement session. The information below was *expensive* to obtain (live cdb on the real +2013 retail client). Do not re-derive it; do not re-guess. Build from it. + +--- + +## 0. TL;DR (read this, then read the rest) + +The indoor "flap"/flicker is **not a bug to be fixed with a point change.** It is the symptom +of a **structural divergence** from how retail renders. We confirmed this by attaching cdb to +the **live retail client** and reading the decompilation. The findings are unambiguous: + +- **Retail has exactly ONE render path: `DrawInside(viewer_cell)`, every frame.** There is **no + inside/outside render branch.** The "outside" branch in `RenderNormalMode` is dead code + (compiler-constant). `is_player_outside` only gates sky/weather/lighting, never the render path. +- **"Entering a building" is NOT a rendering event in retail.** It is *only* the camera sweep + resolving a different `viewer_cell` (an outdoor `CLandCell` → an indoor `CEnvCell`). The render + code never asks "am I inside?". Same path before and after the threshold → **no seam → no flap.** +- **Retail's eye JITTERS ~36 µm at rest** (measured, live). Retail's membership is stable anyway. + So retail's stability is **structural** (coarse per-building visibility robust to jitter), NOT + from a stable eye. **Chasing a byte-stable eye is the wrong target** — retail itself doesn't + have one. +- **We diverged in three ways:** (1) we invented an inside/outside branch + a synthetic + `_outdoorNode`; (2) we do ONE giant unified flood where retail does many small per-building + floods; (3) our camera boom jitters ~36× more than retail's. + +**The decision (user-approved 2026-06-08): Option A — full retail structural port.** Rip out the +branch and the outdoor node. Always root at the real `viewer_cell`. One `DrawInside`. Render +terrain + per-building interiors from *within* that path the way retail does. Phase it; conformance- +test each phase against the measured retail values in this doc; visual-gate. + +**Next session's first move:** run `superpowers:brainstorming` is NOT needed (design is decided); +go to `superpowers:writing-plans` to turn §6 (the design) into a phased implementation plan, then +`superpowers:executing-plans`/`subagent-driven-development`. But FIRST close the open traces in §8. + +--- + +## 1. The decision and its scope + +**Option A — Full retail structural port.** In scope: + +1. **Remove the inside/outside render branch.** Today `GameWindow.cs:7498` does + `if (clipRoot is not null) { DrawInside } else { DrawPortal }`, where + `clipRoot = viewerRoot ?? _outdoorNode` (`GameWindow.cs:7396`). Retail has no such branch. +2. **Root always at the real `viewer_cell`** (the cell the camera-collision sweep resolves — an + outdoor `CLandCell` or an indoor `CEnvCell`), never a synthetic outdoor node. +3. **One `DrawInside(viewer_cell)` per frame.** Terrain + sky draw from *within* it when the flood + "sees outside"; per-building interiors draw per-portal via the terrain BSP. +4. **Per-building view construction** (retail does ~7 small per-building floods/frame), replacing + our single unified flood. + +Explicitly NOT the goal: "make the eye byte-stable" (retail's isn't); "add hysteresis/dead-zone to +the clip" (band-aid, forbidden); "bound the portal re-enqueue churn" (there is no churn — measured). + +The user's words: *"Yes lets do full retail! A!"* and earlier *"NO code of the project is frozen so +all options on the table."* Nothing is frozen. This is a render-orchestration rewrite, done +retail-faithfully, in phases. + +--- + +## 2. Why this took ~4 weeks (the pattern is the diagnosis) + +Over ~4 weeks the "root cause" was declared, with apparatus, **at least seven times**: two-pipe +split → root-at-player-cell → viewer-cell metastability → camera-boom drift → physics rest-jitter → +portal-flood re-enqueue churn → render-position jitter. **Every one was a real, measured +perturbation. Every fix failed or moved the symptom.** That pattern is the signature of a +**system-level problem attacked one stage at a time** (systematic-debugging skill, Phase 4.5: +"3+ fixes failed → question the architecture"). + +**The fundamental issue.** The flicker is a **binary** decision ("is this cell visible: yes/no") +made at a **grazing knife-edge** (the doorway portal, near-zero-area sliver), fed by a **long, +coupled chain that amplifies**: + +``` +physics body → render-position interpolation → camera boom → camera-collision sweep + → viewer cell → render branch → portal flood → clip → VISIBLE / NOT VISIBLE +``` + +Measured amplification: physics body byte-stable → render position jitters µm → eye jitters +~1.3 mm → at the end the continuous wobble is forced into a yes/no at a knife-edge → cell pops in +and out. **It is a pencil balanced on its tip:** it doesn't matter which draft of air tips it, +there's always another. You cannot stabilize a pencil-on-tip by hunting individual air currents. +Every "I found the jitter source!" fix closed one draft while the pencil stayed on its point. + +**Why retail has the same knife-edge but doesn't flicker:** retail uses the *exact same* grazing +clip (we ported it). Retail doesn't flicker because **its structure is robust to the jitter** — +many small per-building visibility decisions, not one giant knife-edge flood. Retail did NOT remove +the jitter (its eye jitters ~36 µm too); it made the *decision* robust to it. **That is the thing +we never did, because we kept patching the noise instead of the structure.** + +--- + +## 3. THE ORACLE — how retail actually renders (measured + decompiled GROUND TRUTH) + +This is the irreplaceable part. It was obtained from **the live retail client** (cdb) + the named +decomp. Cite it; do not re-derive it. + +### 3.1 Retail render architecture: ONE path + +`SmartBox::RenderNormalMode` (`0x453aa0`, decomp line 92635) **always** calls +`DrawInside(viewer_cell)`. The "outside" branch (`LScape::draw`) is **dead code** — the branch +predicate is the Binary-Ninja artifact `edi_2 = -((edi - edi))` = `xor edi,edi; neg edi` = **always +0**, so the inside branch is taken every frame. `is_player_outside` (`0x451e80`, line 90996) returns +nonzero for an outdoor land cell (low 16 bits of `objcell_id` in `[1, 0xFF]`) but is **not called +from the render dispatch** — only from `GameSky::Draw`, UI, and lighting. **There is no +inside/outside render branch in retail.** + +### 3.2 Call graph (from the decomp-flow research agent, verified against addresses) + +``` +SmartBox::RenderNormalMode (0x453aa0, line 92635) + └─ ALWAYS: RenderDevice::vtable->DrawInside(viewer_cell) + → RenderDeviceD3D::DrawInside (0x59f0d0, line 427843) + → PView::DrawInside(indoor_pview, viewer_cell) (0x5a5860, line 433793) + → CEnvCell::curr_view_push(viewer_cell) + → PView::add_views(this, cell->num_stabs, cell->stab_list) + → Render::copy_view(cell->portal_view[-1], null, 4) + → PView::ConstructView(this, viewer_cell, 0xffff) [CEnvCell overload, 0x5a57b0] + → PView::DrawCells(this, result) (0x5a4840, line 432709) + ├─ if outside_view.view_count > 0: LScape::draw(lscape) [terrain + sky] + └─ for each cell in cell_draw_list: draw portals, env geometry, objects + +PView::DrawCells → LScape::draw (0x506330, line 267912) + → GameSky::Draw + → for each land block: RenderDeviceD3D::DrawBlock (0x5a17c0, line 430027) + → DrawLandCell (terrain) ; DrawSortCell → DrawBuilding (0x59f2a0, line 427938) + outdoor_pview->outdoor_portal_list = building->portals <<< KEY + → terrain BSP walk reaches BSPPORTAL leaves (magic "PORT" 0x504f5254): + BSPPORTAL::portal_draw_portals_only (0x53d870, line 326881) + → for i in num_portals: RenderDevice::vtable->DrawPortal(in_portals[i], frame, 1) + → RenderDeviceD3D::DrawPortal (0x59f0e0, line 427852) + → PView::DrawPortal(outdoor_pview, portalPoly, ...) (0x5a5ab0, line 433895) + CBldPortal* bp = outdoor_portal_list[portalPoly->portal_index] + PView::add_views(this, bp->num_stabs, bp->stab_list) + PView::ConstructView(this, bp, portal, ...) [CBldPortal overload, 0x5a59a0] + viewpoint side-test vs portal plane + PView::GetClip(...) ; CEnvCell::GetVisible(bp->other_cell_id) + Render::copy_view(...) + PView::ConstructView(this, other_cell, bp->other_portal_id) [recurse] + if result: PView::DrawCells(this, ...) [draw that building's interior] + +SmartBox::update_viewer (0x453ce0, line 92761) + → compute pivot from part_array + camera_manager->pivot_offset + → choose start cell: indoor (objcell low16 >= 0x100) → AdjustPosition(pivot); outdoor → player->cell + → CTransition: init_object(player, 0x5c) ; init_sphere(1, viewer_sphere, 1.0) ; init_path(cell) + → find_valid_position: + success → set_viewer(sphere_path.curr_pos, 0) ; viewer_cell = sphere_path.curr_cell + else AdjustPosition(sought_eye) → set_viewer ; viewer_cell = that cell + else set_viewer(player->m_position, 1) ; viewer_cell = null + NO snap / NO quantize / NO dead-zone. (The eye jitters anyway — see §3.4.) +``` + +### 3.3 Verbatim decomp excerpts (the load-bearing ones) + +**(a) `RenderNormalMode` branch — the "always DrawInside" proof (lines 92635–92702):** + +```c +this = RenderDevice::render_device->m_bOpenScene; +if (this != 0) { + int32_t edi_2 = -((edi - edi)); // == 0 ALWAYS (xor edi,edi; neg edi) + int32_t ebx_1 = (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ? 1 : 0; + // ... FOV ... + if (edi_2 == 0) { // ALWAYS taken — the INSIDE path + if (ebx_1 != 0) { // viewer cell can see outside → + uint32_t eax_1 = Position::get_outside_cell_id(&this_1->viewer); + LScape::update_viewpoint(this_1->lscape, eax_1); // aim terrain viewpoint outside + } + Render::update_viewpoint(&this_1->viewer); + RenderDevice::render_device_2->vtable->DrawInside(rd2, this_1->viewer_cell); + } else { // DEAD CODE — edi_2 is constant 0 + LScape::update_viewpoint(...); Render::update_viewpoint(...); + Render::set_default_view(); Render::useSunlightSet(1); + LScape::draw(this_1->lscape); + } +} +``` + +**(b) `PView::DrawInside` (lines 433793–433823) — how the indoor flood is set up:** + +```c +void PView::DrawInside(PView* this, CEnvCell* arg2) { + CEnvCell::curr_view_push(arg2); + PView::add_views(this, arg2->num_stabs, arg2->stab_list); + Render::copy_view(arg2->portal_view.data[arg2->num_view - 1], null, 4); + edx_2 = PView::ConstructView(this, arg2, 0xffff); // flood from viewer_cell + PView::DrawCells(this, edx_2); + PView::remove_views(this, arg2->num_stabs, arg2->stab_list); +} +``` + +**(c) `PView::ConstructView(CEnvCell*, 0xffff)` (lines 433750–433789) — the flood loop:** + +```c +void PView::ConstructView(PView* this, CEnvCell* arg2, uint16_t arg3) { + this->outside_view.view_count = 0; + PView::master_timestamp += 1; + this->cell_todo_num = 0; + this->cell_draw_num = 0; + PView::InitCell(this, arg2, arg3); + PView::InsCellTodoList(this, arg2, 0.0); + while (this->cell_todo_num > 0) { + CEnvCell* cell = cell_todo_list.data[this->cell_todo_num - 1]->cell; + if (cell == 0) return; + this->cell_todo_num -= 1; + cell_draw_list.data[this->cell_draw_num++] = cell; // <- membership append + cell->portal_view.data[cell->num_view - 1]->cell_view_done = 1; + if (PView::ClipPortals(this, cell, 0) != 0) // clip → enqueue neighbours + PView::AddViewToPortals(this, cell); + } +} +``` + +**(d) Per-building portal loop — `BSPPORTAL::portal_draw_portals_only` (lines 326940–326953):** + +```c +// Reached at each BSPPORTAL leaf during the terrain BSP walk (front-to-back vs viewer): +int32_t i = 0; +if (this_1->num_portals > 0) do { + int32_t edx_4 = this_1->in_portals[i]; // CPortalPoly* + RenderDevice::render_device->vtable->DrawPortal(/*portal*/edx_4, /*frame*/arg2, /*mode*/1); + i += 1; +} while (i < this_1->num_portals); +``` +…and `PView::DrawPortal` (lines 433895–433933) looks up `outdoor_portal_list[portalPoly->portal_index]`, +`add_views`, then `ConstructView(CBldPortal*)` → if non-empty, `DrawCells` that building's interior. +**This is the ~7 `cv-bld` calls/frame we measured. Per-building, small, robust.** + +**(e) `update_viewer` eye-set (lines 92761–92892) — NO stabilization:** see the call graph §3.2. +The eye is the result of a per-frame `CTransition::find_valid_position` sweep from the pivot to the +sought eye. **No snap / quantize / dead-zone.** (The research agent *inferred* "stable because inputs +stable"; the LIVE trace contradicts that — the eye jitters ~36 µm — see §3.4. The agent did NOT trace +where `viewer_sought_position` is written; that is open trace #1 in §8.) + +### 3.4 LIVE MEASUREMENTS (cdb on retail at the Holtburg cottage doorway, 2026-06-08) + +Retail binary: `C:\Turbine\Asheron's Call\acclient.exe`, **MATCHES** our PDB +(`refs/acclient.pdb`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). PID this session: 32360. + +- **Membership at rest is STABLE.** Camera held still: `PView.cell_draw_num` settled to a long + unbroken run of **2** (brief `4` only at startup). `SmartBox.viewer_cell` pointer = **1 distinct + value** across the whole capture (byte-stable cell). Retail does NOT flap at rest. +- **Retail does PER-BUILDING floods.** `ConstructView(CBldPortal*)` (`0x5a59a0`) fired ~7×/frame, + each `cell_draw_num ≈ 2`. The `CEnvCell` overload (`0x5a57b0`) fired far less. Retail does NOT do + one unified flood. +- **Retail's EYE JITTERS ~36 µm at rest** — the decisive measurement. Reading + `SmartBox.viewer.frame.m_fOrigin` (raw float bits) with the camera held still: + ``` + pub=(431a51ab, 41d1d3c4, 42c0a914) x≈154.32 y≈26.23 z≈96.33 (raw IEEE-754 hex) + pub=(431a51ab, 41d1d3c1, 42c0a914) + pub=(431a51ac, 41d1d3bf, 42c0a914) ← X flips 1 ULP + pub=(431a51ab, 41d1d3cc, 42c0a915) ← Z flips 1 ULP + pub=(431a51ac, 41d1d3b9, 42c0a913) ← Y spans ~19 ULPs + ``` + Decoded jitter: **X ~15 µm, Y ~36 µm, Z ~8 µm.** `pub == sought` (eye uncollided at the open door, + so the jitter is the camera boom itself, not the collision sweep). **Retail's eye is NOT byte-stable.** +- **Compare to acdream** (measured earlier this session via `[pv-input]` at the same doorway): our eye + jitters **~1.3 mm in Y** (≈36× retail), our `RenderPosition` shows 15 distinct values at rest, our + membership oscillates (flood `8↔3`, `6↔3`, etc.). Our physics body (`rawPlayer`) IS byte-stable — + the jitter enters in the camera chain, NOT physics. + +### 3.5 Struct offsets + symbols (from `flap-render-lookup.cdb` / `flap-pos-lookup.cdb`) + +``` +acclient!SmartBox::update_viewer @ 0x453ce0 +acclient!SmartBox::RenderNormalMode @ 0x453aa0 +acclient!SmartBox::is_player_outside @ 0x451e80 +acclient!PView::ConstructView(CEnvCell*, ushort) @ 0x5a57b0 +acclient!PView::ConstructView(CBldPortal*, CPolygon*,int,int) @ 0x5a59a0 +acclient!PView::DrawInside(CEnvCell*) @ 0x5a5860 +acclient!RenderDeviceD3D::DrawInside @ 0x59f0d0 + +struct PView: + +0x000 outside_view : portal_view_type + +0x048 draw_landscape : Int4B + +0x04c outdoor_portal_list : CBldPortal** (set per-building by DrawBuilding) + +0x050 cell_draw_list : DArray + +0x060 cell_draw_num : Uint4B (THE membership count) + +0x064 cell_todo_list : DArray + +0x074 cell_todo_num : Uint4B + +0x078 lscape : LScape* + +struct SmartBox: + +0x008 viewer : Position (the published eye) + +0x050 viewer_cell : CObjCell* (the cell the eye occupies) + +0x058 viewer_sought_position : Position (pre-sweep desired eye) + +0x0f8 player : CPhysicsObj* + +struct Position: +0x004 objcell_id:Uint4B +0x008 frame:Frame +struct Frame: +0x000 qw,qx,qy,qz:Float +0x010 m_fl2gv[9]:Float +0x034 m_fOrigin:Vector3 + ⇒ SmartBox.viewer.objcell_id = +0x0c ; viewer origin x/y/z = +0x44 / +0x48 / +0x4c + ⇒ SmartBox.viewer_sought_position.origin = +0x94 / +0x98 / +0x9c +``` + +--- + +## 4. Our divergences (precise, with file:line) + +| # | Divergence | Where (acdream) | Retail truth | +|---|---|---|---| +| D1 | **Inside/outside render branch** | `GameWindow.cs:7498` `if (clipRoot is not null){DrawInside}else{DrawPortal}`; root at `GameWindow.cs:7396` `clipRoot = viewerRoot ?? _outdoorNode` | No branch. Always `DrawInside(viewer_cell)`. | +| D2 | **Synthetic `_outdoorNode`** (outdoor-as-cell) as root when eye outside | `GameWindow.cs:7396`, `OutdoorCellNode.cs`, `PortalVisibilityBuilder.Build` `if (cameraCell.IsOutdoorNode)` (`PortalVisibilityBuilder.cs:88`) | Root is the real outdoor `CLandCell` the eye occupies. | +| D3 | **One unified flood** (`PortalVisibilityBuilder.Build` from one root) | `RetailPViewRenderer.DrawInside` → `PortalVisibilityBuilder.Build` (`RetailPViewRenderer.cs:43`); look-in via `DrawPortal` → `BuildFromExterior` (`RetailPViewRenderer.cs:92`) | Many small per-building floods via terrain BSP → `DrawPortal` → `ConstructView(CBldPortal)`. | +| D4 | **`MaxReprocessPerCell = 16` cap** (re-enqueue band-aid) | `PortalVisibilityBuilder.cs:51` | No cap; bounded structurally. (And: there is no re-enqueue *churn* — measured `maxPop=1`.) | +| D5 | **`EyeInsidePortalOpening` guard** (degenerate-portal hack) | `PortalVisibilityBuilder.cs` (`EyeInsidePortalOpening`, ~235–244, 793–833) | Retail's 3D clip needs no such special case. | +| D6 | **Reciprocal clip on `ProjectToNdc` not `ProjectToClip`** | `PortalVisibilityBuilder.ApplyReciprocalClip` (~697–747) | acdream split to dodge drift. | +| D7 | **Render-position interpolation layer** (ours, not retail) | `PlayerMovementController.ComputeRenderPosition` (`PlayerMovementController.cs:810`) `Lerp(prev, curr, accumFrac)` | Retail renders at the authoritative position; the only nearby retail cite is the 30 Hz *physics* tick gate (`CPhysicsObj::update_object` :283950), NOT a render-interp. | +| D8 | **Camera boom ~36× looser than retail** | `RetailChaseCamera` (`RetailChaseCamera.cs`) damping + `ApplyConvergenceSnap` (SnapEpsilon 0.0004 m); collision sweep `PhysicsCameraCollisionProbe.SweepEye` | Retail boom jitters ~36 µm; no snap in `update_viewer`. SECONDARY — fix the structure first. | + +D1–D3 are the **primary** structural divergences Option A removes. D4–D8 are accumulated band-aids / +secondary; most fall away once D1–D3 are done, but each must be removed deliberately (each was added +to paper over a real problem — see §7 DO-NOT and the in-code comments). + +--- + +## 5. The render pipeline as it exists today (so you know what you're rewriting) + +- Entry: `GameWindow.cs` render loop, ~7180–7800. `RetailChaseCamera.Update` produces `Position` + (eye) + `ViewerCellId`. `viewerRoot` resolved ~7209–7211; `clipRoot = viewerRoot ?? _outdoorNode` + (7396). Branch at 7498: `DrawInside` (indoor/unified) vs `DrawPortal` (exterior look-in). +- `RetailPViewRenderer` (`src/AcDream.App/Rendering/RetailPViewRenderer.cs`): + `DrawInside(ctx)` → `PortalVisibilityBuilder.Build(rootCell, eye, lookup, viewProj)` (line 43); + `DrawPortal(ctx)` → `PortalVisibilityBuilder.BuildFromExterior(candidateCells, …)` (line 92). + Post-flood: `ClipFrameAssembler.Assemble`, then `DrawLandscapeThroughOutsideView`, + `DrawExitPortalMasks`, `DrawEnvCellShells`, `DrawCellObjectLists`. +- `PortalVisibilityBuilder` (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`): the flood. Ports + `ConstructView`/`ClipPortals`/`AddViewToPortals` BUT as ONE flood with the `MaxReprocessPerCell` + cap, the `EyeInsidePortalOpening` guard, and the NDC reciprocal. `OutsideView` is the + terrain-through-door region. `IsOutdoorNode` special-cases the synthetic outdoor root. +- `PortalProjection` (`PortalProjection.cs`): `ProjectToClip` + `ClipToRegion` — **faithful** port of + retail `PView::GetClip` (`0x5a4320`/`:432344`) + `ACRender::polyClipFinish` (`:702749`, the w=0 + clip). KEEP THIS — it is correct; the problem is never the clip math, it's what feeds it. +- `CellVisibility` (`CellVisibility.cs`): cell membership / `stab_list` / `seen_outside` / InsideSide + side-test — faithful to `CellManager::ChangePosition` (`0x4559B0`) + `grab_visible_cells` (`:311878`). +- Camera: `RetailChaseCamera.cs` (boom, `ApplyConvergenceSnap` from `d2212cf`), + `PhysicsCameraCollisionProbe.cs` (`SmartBox::update_viewer` sweep port), `CameraController.cs` + (picks RetailChaseCamera vs legacy `ChaseCamera`). + +--- + +## 6. The design — Option A (phased; each phase conformance-tested + visual-gated) + +> The fresh session should run `superpowers:writing-plans` to expand this into a task plan. The +> phases below are the architecture; the plan adds the bite-sized steps. + +**Guiding invariant (retail):** every frame, root the render at the *real* cell the camera eye is +in (`viewer_cell`), and run ONE `DrawInside`. Outdoor terrain + per-building interiors are products +of that single path, not of a separate branch. + +**Phase R-A1 — Collapse to one root, one path (remove D1 + D2).** +- Make `clipRoot` = the real cell the camera-collision sweep resolved (`RetailChaseCamera.ViewerCellId` + → the actual `LoadedCell`, outdoor `CLandCell` or indoor `CEnvCell`). Delete the `?? _outdoorNode` + fallback and the `IsOutdoorNode` special-case in `PortalVisibilityBuilder`. +- Delete the `else { DrawPortal(...) }` branch (`GameWindow.cs:7613–7690`). One call site: + `DrawInside(viewer_cell)` every frame. +- Requires: an outdoor `CLandCell` must be a valid `DrawInside` root whose flood immediately "sees + outside" (`OutsideView` full) so terrain draws. This is the retail behavior (`viewer_cell` is a + land cell when outside). **Open design point:** acdream's `LoadedCell` model may not currently + represent the outdoor land cell as a floodable cell — see open trace #3 (§8). Resolve before coding. +- Conformance: at the doorway, the frame *before* and *after* crossing the threshold run the same + code path; `[pv-input]` `outRoot` stops toggling (there is no outRoot concept anymore). + +**Phase R-A2 — Per-building floods (remove D3).** +- Replace the single unified `Build` (when looking at buildings from outside) with retail's + per-building constructions: during the terrain/landblock draw, for each visible building portal, + run a small `ConstructView` rooted at that building portal (the `CBldPortal` overload), flooding + only that building's cells. Port `BSPPORTAL::portal_draw_portals_only` (`0x53d870`) → + `PView::DrawPortal` (`0x5a5ab0`) → `ConstructView(CBldPortal)` (`0x5a59a0`). +- This is the **robustness mechanism** (small coarse per-building visibility absorbs eye jitter). +- Conformance: capture `cell_draw_num` per building ≈ 2 (matches retail §3.4); membership stable as + the (jittering) eye moves within a cell. + +**Phase R-A3 — Remove the band-aids (D4, D5, D6) made dead by R-A1/R-A2.** +- With per-building bounded floods, `MaxReprocessPerCell` (D4), `EyeInsidePortalOpening` (D5), and the + NDC reciprocal (D6) should be removable. Remove each deliberately, re-running the conformance + the + existing `PortalVisibilityBuilderTests`. Do NOT remove `ProjectToClip`/`ClipToRegion` (faithful). + +**Phase R-A4 (optional, secondary) — Tighten the camera boom toward retail (D8); reconsider the +render-position interpolation (D7).** +- Only if, after R-A1–R-A3, residual flicker remains AND it correlates with eye jitter > retail's + ~36 µm. Match retail's boom damping/snap. Do NOT chase byte-stable (retail isn't). Treat the + render-position interpolation as suspect but DO NOT rip it out blindly (it prevents 30 Hz judder; + removing it regressed the branch last time — see §7). + +**Testing strategy (critical — this is how we stop shipping unverified changes):** +- **Conformance tests against the measured retail values in §3.4** (cell_draw_num per building ≈ 2, + membership stable under eye jitter, one path across the threshold). These run WITHOUT the live + client. +- Keep all existing `PortalVisibilityBuilderTests` green where still applicable. +- Keep the 14 `PlayerMovementControllerTests` green. +- **Visual gate is the acceptance test** (the user at the doorway). But the conformance tests are the + PRE-gate — never ship to the visual gate on a red/absent conformance test again. +- Re-attach cdb to retail to capture any NEW retail value the implementation needs (the workflow in + §7 is proven and fast). + +--- + +## 7. DO NOT RETRY (every one of these is evidence-disproven — re-trying wastes days) + +- **"Make the eye byte-stable at rest."** Retail's eye jitters ~36 µm (§3.4, MEASURED). Byte-stable + is the wrong target. My render-position rest-snap fix this session did this, **failed (no change) + AND regressed the inside/outside flap**, and was reverted (`cd974b2` → revert `9b1857a`). The + jitter source is also NOT `RenderPosition` (stabilizing it changed nothing) — it is downstream in + the camera boom / sweep. Don't re-snap RenderPosition. +- **"Bound the portal re-enqueue churn" / bounded-propagation / enqueue-once.** There is **no churn**: + measured `maxPop = 1` across 13k oscillating frames; 0 of 63k reciprocals ever clipped empty + (`ACDREAM_PROBE_PORTAL_CHURN`, this session). The whole bounded-propagation plan + (`docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md` + the + `2026-06-08-portal-flood-enqueue-once-port-design.md` spec) is REFUTED by measurement. The + apparatus commits (`687040b`, `e6fe4c6`, `a866c51`) are fine to keep as probes; the *fix premise* + is dead. +- **Physics rest µm-jitter** (`d6aa526` era). Refuted: 216k standstill records, 0 re-snaps; body + byte-stable. 4 GREEN rest-stability tests prove it. +- **Camera-drift / viewer-cell metastability dead-zone** (2026-06-05 3-part plan). The dead-zone is + ±0.2 mm in `point_inside_cell_bsp`; the eye crosses by metres, not sub-mm — irrelevant to this + symptom. The boom snap (Part 1, `d2212cf`) is already shipped and KEPT. +- **Two-pipe inside/outside split** — that was the ORIGINAL approach, abandoned 2026-05-30. Do not + resurrect it. Retail has neither two pipes NOR a branch — it has ONE path (§3.1). +- **Render-side debounce/grace/hysteresis on the branch or the clip** — forbidden band-aid + (`feedback_no_workarounds`). +- **Trusting a decomp INFERENCE about runtime behavior without a live trace.** This session burned a + fix on the inference "RenderPosition jitter → eye jitter." The cdb-on-retail workflow (§ below) is + the antidote: MEASURE, don't infer. + +--- + +## 8. OPEN TRACES — finish these BEFORE writing the implementation plan + +The oracle is ~90% complete. Three things must be traced/decided first (each is a focused cdb capture +and/or decomp read; the workflow below makes each ~10 min): + +1. **Where `SmartBox::viewer_sought_position` is written** (the camera boom that produces the + ~36 µm-jittering sought eye). The decomp agent did NOT find the write site (it's in the + `camera_manager` / spring-arm chain). Trace it (cdb bp on writes to `SmartBox+0x58`, or read + `CameraManager` methods) to know exactly how tight retail's boom is and what to match in D8. +2. **`PView::ClipPortals` (`0x5a4...`) and `PView::AddViewToPortals` (`0x5a52d0` :433446)** — + the per-cell flood propagation. Not yet read in detail. Needed for a faithful per-building + `ConstructView` port (Phase R-A2). Read both. +3. **How retail's `DrawInside`/`ConstructView` handle a `CLandCell` (outdoor) `viewer_cell`** — i.e. + the pure-outdoor and outside-looking-in root. Confirm the outdoor land cell floods such that + `outside_view` is full and per-building portals render via the terrain BSP. AND decide how + acdream's `LoadedCell`/cell model represents the outdoor land cell as a floodable root (Phase R-A1 + open design point). This is the single biggest unknown for the rewrite. + +Also nice-to-have: `viewer_sphere` radius used in `update_viewer` (the agent didn't look it up; our +port uses 0.3 m — `PhysicsCameraCollisionProbe.ViewerSphereRadius`). + +--- + +## 9. Apparatus (this session — REUSE IT, don't rebuild it) + +**Retail-debugger toolchain (PROVEN this session):** +- Binary: `C:\Turbine\Asheron's Call\acclient.exe` — verified pairs with `refs/acclient.pdb` + (`py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"` → MATCH). +- cdb: `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`. +- Attach + capture pattern (background, tee to a log): + ```powershell + & "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" -p -cf