diff --git a/docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md b/docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md new file mode 100644 index 00000000..501532bc --- /dev/null +++ b/docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md @@ -0,0 +1,161 @@ +# Handoff — collision-object-detection vs retail: a unifying audit ("1 fix for all collision") — 2026-06-24 + +**Branch/worktree:** `claude/thirsty-goldberg-51bb9b`. Named-retail + references live in the MAIN repo +`C:\Users\erikn\source\repos\acdream\`. + +## The ask (user, 2026-06-24) + +> "Deep-dive in how we detect what objects have collision compared to retail. I expect/know there are +> deviations vs retail. Thorough checking of how retail checks what objects have collision and how it +> deviates from our implementation. This should be **1 fix for all collision**." + +So: **NOT another whack-a-mole symptom fix.** Build the retail model of *"which objects collide, and how +the engine decides that"*, map acdream's model against it, enumerate every deviation, and design **one +unified, retail-faithful collision-inclusion mechanism** that replaces acdream's scattered per-channel +hand-rolled filters. This is a research → brainstorm → multi-commit phase. **Brainstorm-gate it** +(`superpowers:brainstorming`) before writing the port. **No guess-patches** — this is the DO-NOT-RETRY +collision area; grep named-retail FIRST, decompile, pseudocode, port, conformance-test. + +## Why now — the pattern the 2026-06-24 session exposed + +This session fixed three collision bugs, and **two of them were the *same shape*: an ad-hoc per-channel +filter that diverges from retail's unified model.** + +| Commit | Issue | The deviation it revealed | +|---|---|---| +| `afd5f2a` | #138-B | Avatar relocated into a dead landblock during PortalSpace (lifecycle, not inclusion) | +| `49d743f` | #146 | **Building collision shell cached at a stale `_liveCenter` frame** — terrain re-bases per apply, the building cache didn't. Streaming-relative-frame staleness. | +| `9743537` | #147 | **Portal-less buildings (city/perimeter walls) skipped** by `if (building.Portals.Count == 0) continue;` — a *transit-feature* filter that wrongly gated *collision*. We used "has a doorway" to decide "is solid". | + +#147 is the smoking gun for the user's intuition: **acdream decides "does this object collide" with +per-channel attributes that have nothing to do with retail's actual predicate.** There are almost +certainly more of these. The goal is to replace the lot with retail's real rule. + +## The retail model — what to research (the oracle) + +Retail (`docs/research/named-retail/acclient_2013_pseudo_c.txt` — grep by `class::method`) decides +collision in two layers. Map BOTH precisely: + +### A. The inclusion predicate — "does this object participate in collision at all?" +- **`PhysicsState` flags** on `CPhysicsObj` (grep `acclient.h` for the `PhysicsState` enum). The + load-bearing ones: + - **`ETHEREAL`** → no collision (pass-through). Confirm acdream honors this on EVERY channel, not just + interaction-pick. (See `[[project_interaction_pipeline]]` ETHEREAL-alone exemption — that's + *selection*, not *collision*; verify the collision side.) + - **`HAS_PHYSICS_BSP` / `HAS_PHYSICS_BSP_PS`** → the object collides via its BSP; otherwise via a + cylinder/sphere. This is the binary dispatch (`[[feedback_retail_binary_dispatch]]`, + `BspOnlyDispatch` flag `0x00010000`). + - Other gates to check: `STATIC`, `REPORT_COLLISIONS`, `IGNORE_COLLISIONS`, `MISSILE`, `NODRAW` (NODRAW + is render-only — confirm it does NOT gate collision), `SCRIPTED_COLLISION`. +- **The object's physics shape**: does it have a `CPhysicsBSP` (collision BSP) vs a `CylSphere`? Retail + uses whatever the dat/weenie says — it does NOT fabricate collision from a render mesh AABB. (acdream's + `IsPhantomGfxObjSource` / mesh-AABB-fallback is a known divergence — #101.) + +### B. The registration / dispatch model — "where does the engine look for colliders?" +- **`CPhysicsObj::FindObjCollisions`** (the per-tick collider) — picks BSP-only vs cyl+sphere on the + flag above, iterates the **per-cell `shadow_object_list`**. +- **`CObjCell::add_shadows_to_cells` / `find_cell_list` / `calc_cross_cells`** — an object registers a + *shadow* into every cell its sphere overlaps; the per-cell list is the broad phase. (acdream ported + this as the A6.P4/BR-7 `ShadowObjectRegistry` flood — see `[[project_physics_collision_digest]]`.) +- **`CBuildingObj::find_building_collisions`** (`0x006b5300`) — the building **shell** BSP, tested on + `part_array->parts[0]`, **independent of the portal list**. (This session's #147 fix aligned acdream + to this.) `CLandBlock::init_buildings` (`0x0052fd80`) builds one building per origin landcell. +- **Terrain**: `CLandCell::find_env_collisions` (terrain polys) + the eye-side cull. +- **EnvCell (indoor)**: `CEnvCell::find_env_collisions` (`0x0052c100`) — the cell's own BSP; **no + building leg** (acdream matches this at `TransitionTypes.cs:2807`). + +**Deliverable from this layer:** a single table — for each object class (terrain, building shell, +EnvCell shell, static stab, scenery, server weenie/door/NPC/item, missile) — *what makes it collide in +retail* (which flags, which shape, which registration), with the named-retail function + address. + +## The acdream model — the code map to audit against the table + +acdream has **separate channels with different inclusion filters**. Walk each and record its de-facto +"does it collide" predicate, then diff against retail's: + +1. **Terrain** — `PhysicsEngine.SampleTerrainWalkable` / `TransitionTypes.cs:2229` (`// no terrain loaded + → allow pass-through`, `:2247`). Re-bases per apply via `AddLandblock(origin)` (so no #146-style + staleness). Filter: terrain residency only. +2. **Buildings** — `TransitionTypes.FindBuildingCollisions` (`:2805`), `PhysicsDataCache.CacheBuilding` + (`:441`), the cache loop (`GameWindow.cs` ~`:6958`). Post-session: re-bases per apply (#146) + + includes portal-less (#147). Remaining filter: `ModelId == 0 → inert` (`:2813`); the `[bldg-channel]` + probe (`ACDREAM_PROBE_BUILDING`, logs `bldOrigin`) is the live lens. +3. **Shadow objects** (statics, scenery, server weenies) — `Core/Physics/ShadowObjectRegistry.cs` + (per-cell flood, BR-7). Registration sites: `GameWindow.cs` `ShadowObjects.Register*` (~`:7196–7526`), + `RefloodLandblock` (`:7595`), `RegisterMultiPart` (`:3835`). Server weenies register via + `OnLiveEntitySpawnedLocked` → Register. **Filters to scrutinize for divergence:** + - `LandblockLoader.IsSupported` (GfxObj/Setup mask only) — does it match retail's "has a physics + shape"? What about `0x0D`/other types? + - **mesh-AABB fallback** / `IsPhantomGfxObjSource` (#101) — acdream fabricates a collision box from + the render mesh when no physics BSP exists. **Retail does not.** Prime deviation. + - cyl-vs-BSP dispatch (A6.P7, `Transition.BspOnlyDispatch`) — verify it matches `HAS_PHYSICS_BSP_PS`. + - editor-marker degrade-hide (#136) — those are render-hidden; confirm collision treatment matches. + - ETHEREAL: does the live-spawn path exclude ETHEREAL weenies from shadow registration? +4. **EnvCell (indoor)** — cell BSP via the transition; `CellTransit` per-cell iteration. +5. **The streaming-relative frame** — #146 proved a cached collision transform can go stale on recenter. + **Audit every cached collision transform** (shadow objects? EnvCell? portal planes?) for the same + staleness; or fold into the #145 cell-relative-frame port so collision frames can NEVER go stale. + +## Suspected deviations to confirm/refute (seed list) + +- **Attribute-as-filter category errors** (the #147 pattern): grep the registration/collision paths for + any `continue`/skip gated on a NON-collision property (portals, name, render flags, degrade, item + type). Each is a candidate divergence. +- **mesh-AABB phantom collision** (#101) — retail-absent; should it exist at all? +- **ETHEREAL not honored on the collision side** of one or more channels. +- **`IsSupported`/type filters** excluding objects retail collides with (or including ones it doesn't). +- **Stale cached collision transforms** beyond buildings (#146 class). +- **Static vs dynamic registration mismatch** — landblock stabs/scenery vs server weenies taking + different inclusion paths with different rules. + +## The unifying target ("1 fix for all collision") + +One retail-faithful **collision descriptor per object**, derived once from its `PhysicsState` + physics +shape (BSP vs cyl, or none = ethereal/no-collide), and **one registration rule** (overlap-based per-cell +shadow lists) used by **every** channel — replacing the per-channel ad-hoc filters. Concretely the +output is likely: (a) a single `ObjectCollides(state, shape)` predicate matching retail's flag logic, +(b) a single registration path all object sources funnel through, (c) deletion of the mesh-AABB phantom +fallback and the attribute-as-filter skips. Expect multi-commit; gate with brainstorming; conformance- +test against named-retail golden values + the existing replay harnesses (`CellarUp*`, `HouseExitWalk*`, +`CornerFlood*`, the new `Issue147ArwicBuildingsDumpTests` fixture). + +## Method (mandatory — CLAUDE.md workflow) + +1. `superpowers:brainstorming` to scope the phase + agree the unified model BEFORE code. +2. **Grep named-retail FIRST** for every function in the retail-model table; decompile via Ghidra MCP + (`patchmem.gpr`, port 8081) for any single function that's noisy in the bulk text. +3. Cross-reference **ACE** `Physics/Common` (PhysicsObj, ObjCell, shadow lists) + **ACViewer** + `Physics/Common` + **holtburger** `spatial/physics.rs` for the client-side read. +4. Write pseudocode (`docs/research/*_pseudocode.md`), port faithfully, conformance-test, then one + comprehensive user visual gate. + +## Apparatus already in place (use it, don't rebuild) +- `ACDREAM_PROBE_BUILDING` → `[bldg-channel]` (now logs `bldOrigin`) + `[entity-source]` (BSP vs Cylinder + classification per static — directly shows what collision shape each object got). +- `ACDREAM_CAPTURE_RESOLVE=` → per-player-resolve JSONL (grounded / contactPlane / collisionNormal + per frame). Heavy (FPS) — short targeted runs only; the `analyze_*` streaming-tally pattern works + (255k records in seconds). +- `ACDREAM_PROBE_ENT` / `EntityVanishProbe` → avatar draw-set lifecycle (#138-B). +- `Issue147ArwicBuildingsDumpTests` → the dat-dump pattern for `LandBlockInfo.Buildings`/`.Objects` + (portal counts, model ids); clone it for any landblock/object-class inventory. +- Live client: user `notan` / `MittSnus81!`, ACE `127.0.0.1:9000`; graceful close before rebuild. + +## Context — what shipped this session (the motivation) +`afd5f2a` #138-B avatar-vanish, `49d743f` #146 building-frame re-base, `9743537` #147 portal-less-wall +collision. ISSUES #146 DONE, #147 walls DONE (terrain-3%-grounded residual re-scoped LOW — verify it's +even real before acting; see #147). The deep #145 cell-relative-frame port is still its own future phase +and is the natural home for "collision frames can never go stale." + +## Paste-ready prompt for the next session + +> Deep-dive + unifying fix: **how retail decides which objects have collision, vs acdream.** Read this +> handoff (`docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md`) and the +> physics digest (`claude-memory/project_physics_collision_digest.md`) FIRST. Goal: build retail's +> collision-inclusion model (the `PhysicsState`/ETHEREAL/HAS_PHYSICS_BSP predicate + the per-cell +> shadow-list registration + the building-shell/terrain/EnvCell channels — grep named-retail, cite +> addresses), map acdream's per-channel filters against it (terrain / FindBuildingCollisions / +> ShadowObjectRegistry / EnvCell / mesh-AABB phantom / IsSupported / the cyl-BSP dispatch), enumerate +> every deviation, and propose ONE unified retail-faithful collision-inclusion mechanism to replace the +> ad-hoc filters. **Report-first / brainstorm-gated, no guess-patches** (DO-NOT-RETRY collision area). +> Recent precedent (the per-channel filter divergences this is meant to end): #146 (`49d743f`), +> #147 (`9743537`).