diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a95a8749..71a3d0cb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -56,18 +56,24 @@ parts of the house. Passing through → a brief **GREY** flap (world background) spot → the WHOLE screen is grey and stays; **turning the camera clears it, turning back → grey again** (camera-direction dependent, player stationary). -**Root cause / status (BY CLASS — unconfirmed for this instance):** the doorway-FLAP family. Grey = -the world/sky background drawn via the null-clipRoot legacy fallback (**AD-21**) when the renderer has -no indoor visibility root that frame. The camera-direction dependence points at the **camera-EYE -viewer-cell resolution** (**AD-20**): the eye seats via the *player's* cell stab-list, so a -connecting-room cell/portal topology this house type introduces can seat the eye in the wrong/no cell -→ viewer root null → grey; turning re-seats the eye. **Distinct from #176/#181** (a LIT flicker, -parked) — this is a GREY/background flap. SSOT: `claude-memory/project_render_pipeline_digest.md` -("root at VIEWER cell"; flap DO-NOT-RETRY table). +**Root cause — NARROWED (report-only; retail SEAMLESS at the same spot → real acdream bug). FULL HANDOFF: +`docs/research/2026-07-08-186-connector-grey-flap-handoff.md`.** The eye seats in a sparse 5-render-poly +CONNECTOR cell `0xF6820118` (between player room `0116` and room `0117`), in the doorway threshold looking +BACK toward `0116`; acdream's render DROPS `0116` (its back-portal is side-culled) so the doorway aperture +shows the fog clear color = grey. Retail keeps `0116` drawn. **RULED OUT (do not revisit):** null-root/AD-21 +(root valid indoor 88248/88249 frames); viewer-cell root-lag/AD-20 (`eyeInRoot=Y` — eye genuinely inside +`0118`'s BSP); the color-clear gating (retail's gated `Clear` @ DrawCells `0x005a48a9` is depth/stencil, +fires AFTER `LScape::draw` — the tempting "3-line fix" is DEAD). Confirmed via workflow `wf_362bbeda-933` +(4 tracers + synthesis) + live `ACDREAM_PROBE_FLAP` capture + offline cell dump. It's the doorway-FLAP +FLOOD/PICK family (frozen render, DO-NOT-RETRY-heavy — the deleted `EyeInsidePortalOpening` rescue, the +`PortalSideEpsilon` root-lag). -**Next step (apparatus, when resumed):** `ACDREAM_PROBE_FLAP=1` capture at the exact connecting room — -stop where grey, turn to clear, turn back to grey ×2. Confirm the viewer-cell root goes null there, -which cell the eye seats in, and whether the portal flood admits the connecting room. +**Next step (retail cdb, do FIRST — binary MATCHES `refs/acclient.pdb`):** at the grey pose, `bp +SmartBox::update_viewer 0x00453ce0` (dump `viewer_cell` id) and/or `bp PView::DrawCells 0x005a4840` (dump +`cell_draw_list`). retail `viewer_cell==0116` → PICK fix (acdream over-switches to the connector / +boom coasts the eye in at `pulledIn=-0.00`); retail roots `0118` but draws `0116` too → FLOOD fix +(retail admits the back-portal; acdream's side-CULL is the site). Apparatus + code sites + DO-NOT-RETRY +in the handoff. **Acceptance:** no grey flap passing through OR stopping in the connecting room at any camera angle; regression pass on the outside→inside doorway + other multi-room houses. diff --git a/docs/research/2026-07-08-186-connector-grey-flap-handoff.md b/docs/research/2026-07-08-186-connector-grey-flap-handoff.md new file mode 100644 index 00000000..66db80ea --- /dev/null +++ b/docs/research/2026-07-08-186-connector-grey-flap-handoff.md @@ -0,0 +1,149 @@ +# Handoff: #186 — indoor GREY flap at a top-floor connecting room (new house type) + +**Date:** 2026-07-08 · **Status:** ROOT CAUSE NARROWED (report-only) — fix NOT started. It lives in +the FROZEN portal-flood / viewer-cell render code (the doorway-FLAP family that cost weeks), so it needs +a **retail cdb trace** to pin the last fork BEFORE any code, then a careful change. Milestone: M1.5. + +**Read these first, in order:** +1. This handoff. +2. `claude-memory/project_render_pipeline_digest.md` — the render/doorway-FLAP SSOT + DO-NOT-RETRY table + ("root at VIEWER cell"; the deleted EyeInsidePortalOpening rescue; PortalSideEpsilon root-lag). +3. `docs/architecture/retail-divergence-register.md` rows **AD-17** (≤8 clip-plane / union-AABB scissor), + **AD-20** (camera-sweep fallback seeds eye cell from PLAYER cell), **AD-21** (null-clipRoot outdoor fallback). +4. `docs/ISSUES.md` #186. + +--- + +## 0. Symptom (user, live) + +Top floor of a **house type not tried before**; a connecting opening/room between two parts of the house. +Passing through → a brief **GREY** flap (the framebuffer clear color). Stopping at the precise spot → the +WHOLE screen is grey and stays; **turning the camera clears it, turning back → grey again** (camera-DIRECTION +dependent, player stationary). A jump is NOT involved (that's #185). **Retail is SEAMLESS at the same spot +(user-confirmed) → this is a real acdream bug, not inherent.** + +## 1. The cells + the grey pose (ACDREAM_PROBE_FLAP capture) + +Landblock `0xF682`. Three cells, all envId `0x0365`, cell-struct origin `(132,84,62)`: + +| cell | render polys | physics polys | portals | role | +|---|---|---|---|---| +| `0xF6820116` | **15** | 12 | →`0117,0118,0113` | player's room (closed) | +| `0xF6820117` | **10** | 6 | →`0118,0116,0115,0119` | next room (closed) | +| `0xF6820118` | **5** | 3 | →`0117,0116` | **thin connector** (root when grey) | + +Grey-pose facts (from `[flap]`/`[flap-cam]`/`[flap-sweep]`, 88,249 frames; the grey frames are `vis=1` +`root=0xF6820118`, ~6,725 frames): +- `[flap-cam]`: `root=viewerCell=0xF6820118 playerCell=0xF6820116 eyeInRoot=Y playerInRoot=n + terrain=Skip outVisible=False eye=(134.81,83.59,69.07) player=(137.30,83.32,66.82)`. +- `[flap]` grey line: `root=0118 | p0->0x0117 D=0.14 TRV proj=4 clip=0 | p1->0x0116 D=0.18 CULL proj=5 + clip=4 || outPolys=0 vis=1`. (Dominant D to 0117 ≈ 0.14; ALWAYS positive 0.00–0.50 → eye is genuinely + INSIDE 0118, never across the plane.) +- `[flap-sweep]`: `ok=True resolved=Y pulledIn=-0.00` (the chase boom reaches FULL distance, NO collision + pull-in, seating the eye in the connector). + +**Read of the geometry:** the eye sits in the sparse connector `0118`, in the doorway threshold, looking +BACK toward the player's room `0116`. `p1->0116` is ON-screen (proj=5 clip=4) but side-CULLED (eye ~0.18–0.31 +on `0116`'s side of the p1 plane — a back-portal). `p0->0117` is genuinely OFF-screen (`clip=0` with proj≥3 += off-screen per the builder's own probe doc, `PortalVisibilityBuilder.cs:818-821`). So no neighbour floods, +only the 5-poly connector draws, and its shell doesn't cover the forward view → the framebuffer clear color +(fog, `GameWindow.cs:8968`) shows through = GREY. Turning re-aims off the back-portal → a room floods → picture. + +## 2. What is CONFIRMED / RULED OUT (workflow `wf_362bbeda-933`, 4 tracers + synthesis, + my own re-checks) + +- ❌ **NOT null viewer-root / AD-21** — the render root is a valid indoor cell on 88,248/88,249 frames + (one lone `[outdoor-node] root=OUT`). (This refutes THE FIRST hypothesis in this investigation.) +- ❌ **NOT viewer-cell root-lag / AD-20** — `eyeInRoot=Y` at every grey frame: the eye is geometrically + INSIDE `0118`'s BSP. The root is the fresh eye-derived swept `curr_cell` (`PhysicsCameraCollisionProbe.cs:92`), + NOT a stale player-seeded fallback. (Refutes the workflow synthesis's own leading "31 cm gap" theory — + the eye is NOT across the boundary; I checked the `eyeInRoot` flag directly.) +- ❌ **NOT the flood-admission being unfaithful** — the empty-clip cull (`PortalVisibilityBuilder.cs:320-324`) + and the back-portal side-CULL (`:292-298`, `CameraOnInteriorSide :857-863`) are faithful ports: + retail `PView::ClipPortals` gates neighbour processing on `if (ecx_8 != 0)` (pc 005a5660) and + `AddViewToPortals` on `view_count != 0` (pc 005a5357). #177 already proved acdream's flood is retail-identical. +- ❌ **NOT the color clear (the tempting "3-line fix" — DEAD END, do not revisit).** Retail's DrawCells + (`0x005a4840`, decomp line 432709) has its only `Clear` INSIDE `if (outside_view.view_count > 0)` + (line 432715), right AFTER `LScape::draw` (the landscape draw, 432719). The Clear is + `Clear(4, &RGBAColor_Black, 1.0f)` (`0x820fc0 = RGBAColor_Black`) — but clearing COLOR *after* drawing the + landscape would erase it, so that `4` flag is **depth/stencil, not color**. So retail's color clear is NOT + gated here, and matching "don't color-clear a sealed interior" would be WRONG. (This is why I did the cdb + prep — it killed a wrong fix before shipping.) +- ✅ **It IS a real acdream bug** (retail seamless) in the **doorway-FLAP flood/pick family**: acdream drops + the room the camera looks back at (`0116`) from the connector root; **retail keeps it drawn.** + +## 3. THE ONE OPEN FORK → the retail cdb trace (do this FIRST in the new session) + +Retail is seamless, so retail either (A) **roots at the player's room `0116`, not the connector** (a +viewer-cell PICK divergence — acdream over-switches to `0118`), or (B) **roots at `0118` but its flood still +admits `0116`** (a FLOOD divergence — retail keeps the back-portal / uses an on-screen sliver acdream drops). +Static decomp cannot decide; observe retail. + +**Toolchain (CLAUDE.md "Retail debugger toolchain"; binary CONFIRMED pairs with `refs/acclient.pdb` via +`py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"` → MATCH).** +- User: launch retail, connect to local ACE, walk to the SAME connecting room, and **HOLD the grey pose** + (so most captured frames are it). +- `bp acclient!SmartBox::update_viewer` (`0x00453ce0`) → dump `viewer_cell` (a `CObjCell*`, `acclient.h:35194`) + and its cell id. AND/OR `bp acclient!PView::DrawCells` (`0x005a4840`) → dump `cell_draw_num` + + `cell_draw_list.data[]` ids (`acclient.h:45939-45940`) — the drawn cell set. +- Decide: retail `viewer_cell == 0116` → **PICK fix** (acdream's viewer-cell pick/hysteresis over-retains + the connector — `CellTransit.cs:862-916 BuildCellSetAndPickContaining`, or the camera boom coasting the eye + into the connector at `pulledIn=-0.00` where retail's PathClipped `viewer_sphere` sweep hard-stops in + `0116`). retail `viewer_cell == 0118` but `cell_draw_list` ⊇ `0116` → **FLOOD fix** (retail admits the + back-portal from the connector; acdream's side-CULL / the deleted EyeInsidePortalOpening rescue is the site). +- cdb watchouts (CLAUDE.md): `qd` ONLY at a top-level threshold, never inside a conditional bp action (strands + cdb → kills retail); `dt acclient!Class @ecx field` for reads (`@@c++` is broken here); `.sympath ` + (no `srv*`, the `;` splits); spaced `.if ()` lets `qd` detach. + +## 4. Apparatus (re-create in the new session — temp files won't persist) + +- **Flap capture:** launch acdream with `ACDREAM_PROBE_FLAP=1` (canonical live cmd, CLAUDE.md). Emits per + frame: `[flap]` (root + per-portal proj/clip/side, `PortalVisibilityBuilder.cs:790`), `[flap-cam]` + (`root/viewerCell/playerCell/eyeInRoot/playerInRoot/terrain/outVisible`, `GameWindow.cs:11142-11152`), + `[flap-sweep]` (`ok/resolved/pulledIn/viewerCell`, `PhysicsCameraCollisionProbe.cs:74-87`), `[outdoor-node]` + (`root=IN/OUT`). ⚠️ heavy (per-frame); output goes to the raw background-task `.output`, not the Tee'd log. + Have the user HOLD the grey pose + turn to clear + turn back a couple times. +- **Offline cell geometry dump:** `tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs` + (`Dump_ConnectorCells_ShellAndCollision`) loads `0116/0117/0118` from the dat and prints render/physics poly + counts + normal-axis buckets. No live launch needed. (That's how the §1 table was produced.) +- **Decomp workflow:** `wf_362bbeda-933` (this session) — the 4-tracer + synthesis analysis; results in its + journal under the session's `subagents/workflows/` dir. + +## 5. DO-NOT-RETRY (each already refuted or a known trap) + +- Do NOT re-open the **null-root / AD-20 / AD-21** angle — root is valid, `eyeInRoot=Y`. +- Do NOT implement the **color-clear gating** — retail's gated `Clear` is depth/stencil (post-`LScape::draw`); + matching it would add non-retail stale-content. +- Do NOT re-add the deleted **`EyeInsidePortalOpening` rescue** (`PortalVisibilityBuilder.cs:314-319`) blindly + — it caused the `0171↔0173` flood-cycle churn (the ORIGINAL doorway flap). +- Do NOT widen **`PortalSideEpsilon`** (`:38-56`) — a documented root-lag band, NOT retail (F_EPSILON=0.0002); + BR-4 refuted tightening/widening without eye-exact tracking. +- Do NOT "fix" the flood by propagating an empty/edge-on portal — the empty-clip cull is retail-faithful. +- The sparse connector geometry (`0118` = 5 render / 3 physics polys) is FAITHFUL to the dat (retail draws the + same). It is context, not the bug — the bug is that `0116` isn't drawn, not that `0118` is thin. + +## 6. Key code sites + +- Frame-top clear (unconditional color+depth+stencil to fog): `GameWindow.cs:8968-8983`. **NOT the fix.** +- Viewer-cell resolution: `GameWindow.cs:9137-9148` (`viewerCellId = _retailChaseCamera.ViewerCellId`). +- Camera boom sweep: `PhysicsCameraCollisionProbe.cs` (`SweepEye`, PathClipped, `pulledIn`, `r.CellId=curr_cell`). +- Viewer-cell PICK: `CellTransit.cs:862-916` (`BuildCellSetAndPickContaining`, center-based `point_in_cell`, + current-cell-first hysteresis, port of `find_cell_list` pseudo_c:308788-308825). +- Portal FLOOD: `PortalVisibilityBuilder.cs` (`Build`; side-test `CameraOnInteriorSide :857-863`; empty-clip + cull `:320-324`; `PortalSideEpsilon :38-56`; the off-screen-vs-sliver note `:818-821`). + +## 7. Retail decomp anchors + +- `PView::DrawCells` `0x005a4840` (decomp line 432709): Clear gated on `outside_view.view_count>0` (432715), + Clear at 432732 (depth/stencil, post-`LScape::draw`); cells drawn unconditionally after (432815+). +- `SmartBox::update_viewer` `0x00453ce0`: `viewer_cell = sphere_path.curr_cell` (the render root = swept cell). +- `PView::ClipPortals` empty-clip gate `if(ecx_8!=0)` pc 005a5660; `AddViewToPortals` `view_count!=0` pc 005a5357. +- `PView::InitCell` side test decomp:432962. `RGBAColor_Black` @ `0x820fc0`. + +## 8. Acceptance +- No grey flap passing through OR stopping in the connecting room at any camera angle; matches retail's seamless. +- Regression: the outside→inside doorway + other multi-room houses + the #177/#181 render-flap suites unchanged. + +## 9. Context: this session also FIXED #185 (unrelated, DONE + gated) +The outdoor-stairs "invisible wall" (`07c5b832`) — a shadow part-id uint32 overflow dropping stair collision. +Unrelated to #186 (that was collision/registration; #186 is render/visibility). See +`docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md`. diff --git a/tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs new file mode 100644 index 00000000..10b1b1e9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #186 diagnostic (report-only): dump the render-shell + collision geometry of the +/// connecting-room grey-flap cells (0xF6820116 player room, 0xF6820117 next room, +/// 0xF6820118 the connector root where the frame greys). Answers: is 0118 a CLOSED +/// shell (walls/floor/ceiling covering every direction bar the portal apertures) or a +/// bare connector? And does it carry collision geometry (so the camera sweep would +/// hard-stop) or none (so the boom coasts to a degenerate spot)? Skips without the dat. +/// +public class Issue186ConnectorCellGeometryInspectionTests +{ + private readonly ITestOutputHelper _out; + public Issue186ConnectorCellGeometryInspectionTests(ITestOutputHelper output) => _out = output; + + private static string? DatDir() + { + var d = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); + return Directory.Exists(d) ? d : null; + } + + [Fact] + public void Dump_ConnectorCells_ShellAndCollision() + { + var datDir = DatDir(); + if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + foreach (uint cellId in new[] { 0xF6820116u, 0xF6820117u, 0xF6820118u }) + { + var env = dats.Get(cellId); + if (env is null) { _out.WriteLine($"cell 0x{cellId:X8}: EnvCell NOT FOUND"); continue; } + + var environment = dats.Get(0x0D000000u | env.EnvironmentId); + environment!.Cells.TryGetValue(env.CellStructure, out var cs); + + var portals = env.CellPortals?.Select(p => $"0x{(0xF6820000u | p.OtherCellId):X8}") ?? Enumerable.Empty(); + _out.WriteLine($"=== cell 0x{cellId:X8} envId=0x{env.EnvironmentId:X4} struct={env.CellStructure} " + + $"pos=({env.Position.Origin.X:F2},{env.Position.Origin.Y:F2},{env.Position.Origin.Z:F2}) ==="); + _out.WriteLine($" CellPortals -> [{string.Join(",", portals)}] VisibleCells={env.VisibleCells?.Count ?? 0}"); + + if (cs is null) { _out.WriteLine(" CellStruct: NULL"); continue; } + + int renderPolys = cs.Polygons?.Count ?? 0; + int physPolys = cs.PhysicsPolygons?.Count ?? 0; + bool physBsp = cs.PhysicsBSP?.Root is not null; + bool cellBsp = cs.CellBSP?.Root is not null; + _out.WriteLine($" renderPolys={renderPolys} physicsPolys={physPolys} physicsBSP={(physBsp ? "YES" : "NO")} cellBSP={(cellBsp ? "YES" : "NO")}"); + + // Render-shell normal coverage: bucket each render poly's local normal by + // dominant axis to see whether the shell encloses (has +/-X,+/-Y,+/-Z faces). + if (cs.Polygons is not null && cs.VertexArray?.Vertices is not null) + { + var buckets = new Dictionary(); + foreach (var poly in cs.Polygons.Values) + { + var n = PolyNormal(poly, cs.VertexArray); + if (n is null) continue; + buckets.TryGetValue(DomAxis(n.Value), out int c); + buckets[DomAxis(n.Value)] = c + 1; + } + _out.WriteLine(" render-shell normal buckets: " + + string.Join(" ", buckets.OrderBy(k => k.Key).Select(k => $"{k.Key}={k.Value}"))); + } + } + } + + private static Vector3? PolyNormal(DatReaderWriter.Types.Polygon poly, DatReaderWriter.Types.VertexArray va) + { + if (poly.VertexIds is null || poly.VertexIds.Count < 3) return null; + if (!va.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var a)) return null; + if (!va.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var b)) return null; + if (!va.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var c)) return null; + var n = Vector3.Cross( + new Vector3(b.Origin.X, b.Origin.Y, b.Origin.Z) - new Vector3(a.Origin.X, a.Origin.Y, a.Origin.Z), + new Vector3(c.Origin.X, c.Origin.Y, c.Origin.Z) - new Vector3(a.Origin.X, a.Origin.Y, a.Origin.Z)); + return n.LengthSquared() < 1e-9f ? (Vector3?)null : Vector3.Normalize(n); + } + + private static string DomAxis(Vector3 n) + { + float ax = MathF.Abs(n.X), ay = MathF.Abs(n.Y), az = MathF.Abs(n.Z); + if (ax >= ay && ax >= az) return n.X >= 0 ? "+X" : "-X"; + if (ay >= ax && ay >= az) return n.Y >= 0 ? "+Y" : "-Y"; + return n.Z >= 0 ? "+Z" : "-Z"; + } +}