# Research: Campaign P Slices P3 (remote-object residuals) and P4 (world specials) **Filed:** 2026-07-29. **Scope:** research only — no source files modified, no build/test run. Ghidra MCP was checked at both `127.0.0.1:8081` and `127.0.0.1:8080` and was **unreachable** at research time (`curl` exit 7, connection refused on both ports) — every citation below comes from `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C) cross-checked against `references/ACE/Source/ACE.Server/Physics/` where available. Any place BN's decompile is ambiguous and I could not resolve it against ACE is flagged explicitly as an **open question** rather than guessed. All addresses are the Sept 2013 EoR build (matches `refs/acclient.pdb`). Line numbers are into `acclient_2013_pseudo_c.txt` unless noted. --- ## 0. Binding context copied from the physics/collision digest Per CLAUDE.md, the digest's DO-NOT-RETRY guidance is binding on this research and on whichever agent ports it. Copied verbatim (paraphrased where noted) from `claude-memory/project_physics_collision_digest.md` (3-day-old snapshot — verify against current code before treating as fact, which this doc does throughout): - **TS-46 / window-climb DO-NOT-RETRY (2026-07-06, `aa96d7ad`):** the player capsule top was fixed by callers passing `sphereHeight: 1.835` (not `1.2`); `SpherePath.InitPath` itself was **untouched** so captured-1.2 replay fixtures stay identical. The dat human Setup `0x02000001` spheres are **`(0,0,0.475) r=.48`** and **`(0,0,1.350) r=.48`** (top = Height = 1.835). Retail uses the list verbatim via `CPhysicsObj::transition` (0x00512dc0) → `init_sphere`. The window climb itself was **not** a step-up-budget bug — do not add a height-budget check to the step-down accept path when revisiting this area. - **AD-25 rebuild DO-NOT-RETRY (2026-07-07, `8bb8b204`→`54d56229`):** the bleed mechanism is `frames_stationary_fall` (fsf), **not** `cached_velocity` — `cached_velocity = (resolved−old)/dt` is a separate retail reporting value read only by `get_velocity`, never fed to the integrator; acdream correctly keeps both `Velocity` and `CachedVelocity` fields — do not collapse them. `PhysicsObjUpdate.HandleAllCollisions` is the ported function; the velocity response is gated on `candidateMoved` (retail pc:283657) — skipping the response when the candidate didn't move lets gravity rebuild the velocity instead of re-zeroing it, or the body re-wedges. This rebuild is what P3.2 must extend to the remote sweep, not re-derive from scratch. - **#184 remote de-overlap DO-NOT-RETRY (2026-07-08, Slice 3):** creatures are **Sphere-type**, not cylinders, for collision response — do not touch registration when working the mover-side (`ObjectInfo`) flags. Retail's own crowd "pincer" (player starts inside two overlapping spheres) wedges in retail too (`validate_transition` 0x0050aa70:272593 restores `curr_pos` on any non-clean-OK step) — this is not a bug to chase. - **feedback_retail_per_cell_shadow_list:** retail collision is a per-cell `shadow_object_list`, portal-flood-registered. acdream's `ShadowObjectRegistry` approximates but does not fully replicate this — relevant background for #165 (a wrong membership/registration theory is the #1 historical false lead in this codebase; verify collision-set membership before touching response math). - **feedback_apparatus_for_physics_bugs:** 3 failed speculative fixes on any item below = STOP and build capture/replay apparatus (`ACDREAM_PROBE_RESOLVE`, `ACDREAM_CAPTURE_RESOLVE`) before a 4th attempt. This applies most acutely to #165 (see §2.3). - **feedback_bn_decomp_field_names:** BN's heuristic field names, bitfield mush, and stack-slot mis-attribution for many-argument `__thiscall`s are a known artifact class. Section 3.1 below (AP-71's `restriction_obj`) hits this directly — the same field name is used for two apparently different purposes in two different functions, and I did not resolve it (Ghidra MCP down); flagged as an open question, not guessed. - **feedback_verify_subagent_claims_against_source:** every code claim below was verified by reading the actual current file at the cited path/line as part of this research pass (2026-07-29), not carried from memory. --- ## 1. P3.1 — TS-46: verbatim Setup sphere list into the transition ### 1.1 Retail: `CPhysicsObj::transition` seeds the SpherePath from Setup `CPhysicsObj::transition` (0x00512dc0, lines 280904–280957): ```c CTransition* CPhysicsObj::transition(CPhysicsObj* this, Position* arg2 /*start*/, Position* arg3 /*end*/, int32_t arg4 /*state*/) { CTransition* result = CTransition::makeTransition(); if (result == 0) return 0; init_object(result, this, get_object_info(this, result, arg4)); CPartArray* pa = this->part_array; uint32_t numSphere = (pa != 0) ? CPartArray::GetNumSphere(pa) : 0; if (pa == 0 || numSphere == 0) init_sphere(result, /*count=*/1, &dummy_sphere, /*scale=*/1.0f); else { float scale = this->m_scale; CSphere* spheres = CPartArray::GetSphere(pa); // Setup's authored list init_sphere(result, CPartArray::GetNumSphere(pa), spheres, scale); } init_path(result, this->cell, arg2, arg3); // frames_stationary_fall seed from transient_state bits 0x10/0x20/0x40 ... if (find_valid_position(result) != 0) return result; return 0; } ``` **FACT.** The sphere source is `CPartArray::GetSphere(part_array)` — the Setup's *own* CSphere array — not a two-scalar (radius, height) reconstruction. `CPartArray::GetNumSphere` bounds it (see below, `≤ 2`). ### 1.2 Retail: `SPHEREPATH::init_sphere` (0x0050c670, lines 274093–274128) ```c void SPHEREPATH::init_sphere(SPHEREPATH* this, uint32_t count, CSphere* src, float scale) { this->num_sphere = (count <= 2) ? count : 2; // HARD CAP AT 2 for (i = 0; i < this->num_sphere; i++) { this->local_sphere[i].center = src[i].center * scale; // per-sphere scale this->local_sphere[i].radius = src[i].radius * scale; } // local_low_point = local_sphere[0].center, .z -= local_sphere[0].radius this->local_low_point = { local_sphere[0].center.x, local_sphere[0].center.y, local_sphere[0].center.z - local_sphere[0].radius }; } ``` **FACT.** Each of the (≤2) spheres carries its **own origin and radius**, each independently scaled by `m_scale` (the object's wire `ObjScale`). This is a genuine list, not a symmetric two-scalar capsule — a creature whose foot sphere and head sphere have different radii (or non-collinear origins) is representable in retail and is NOT representable in acdream's current `(radius, height)` API. ### 1.3 acdream's current API — the two-scalar reconstruction `SpherePath.InitPath` in `src/AcDream.Core/Physics/TransitionTypes.cs` takes `(sphereRadius, sphereHeight)` and reconstructs: - foot sphere: center `(0,0,radius)`, radius `radius` - head sphere: center `(0,0,height − radius)`, radius `radius` This assumes **one radius for both spheres** and derives the head center purely from `height`. Every `ResolveWithTransition` caller passes these two scalars: | Caller | File:line | Radius/height source | |---|---|---| | Local player | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1372` (call at `:1382` `moverFlags:`), `:1795` (call at `:1822`) | `ApplyStepHeights` in `src/AcDream.App/Input/PlayerModeController.cs:533-565` reads `Setup.StepUpHeight`/`Setup.StepDownHeight` (see §1.5) but the RADIUS/HEIGHT scalars themselves are set elsewhere — human Setup 0x02000001 hardcode (0.48/1.835), confirmed correct for the human Setup per the TS-46 window-climb fix. | | Remote (grounded/airborne DR sweep) | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:343` (moverFlags at `:372`), `:697` (moverFlags at `:707`) | `GetSetupCylinder` — `(setup.Radius, setup.Height) × ObjScale` (a single radius, not the sphere list) — Slice 3 (#184) narrowing. Falls back to human 0.48/1.835 if `deR < 0.05f`. | | Ordinary (non-remote-DR, non-projectile) | `src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs:127` (moverFlags at `:137`) | same `radius, height` pattern (params to the method), Setup-sourced by its caller. | | Projectile | `src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs:336` | Setup-local **single**-sphere sweep — already list-correct because retail projectile Setups (arrow/bolt/spell) install exactly one sphere; documented in the live-entity-runtime memory ("Setup shapes ... not always centered"). Out of TS-46's scope — already faithful for the 1-sphere case. | Camera probe | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:49` (moverFlags at `:68`) | fixed viewer sphere, not Setup-driven — retail's viewer path also uses a synthetic single sphere (`viewer_sphere`, line 92865) — **not** in TS-46's scope. | `GetSetupCylinder` (grep-confirmed): `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` — returns `(setup.Radius, setup.Height)`, i.e. it already reads DAT Setup fields for radius/height, but those are the Setup's **cylinder** fields (`CSetup.Radius`/`CSetup.Height`, used for e.g. selection/picking), not the **sphere list** (`CSetup.Spheres[]`, a parallel/different field). Confirming this distinction requires reading `DatReaderWriter.DBObjs.Setup`'s actual field set (not done in this pass — flagged in §5). ### 1.4 API-migration sketch (two scalars → sphere list) **Goal:** `SpherePath.InitPath` should accept `IReadOnlyList<(Vector3 Center, float Radius)>` (≤2 elements, scaled by `ObjScale` by the caller — matching retail applying `m_scale` inside `init_sphere` itself) instead of `(sphereRadius, sphereHeight)`. Two migration shapes, in order of preference: 1. **Overload, not replacement.** Add `SpherePath.InitPath(IReadOnlyList spheres, ...)` alongside the existing `(radius, height)` overload; keep the existing overload as a thin wrapper that synthesizes a 2-sphere list `[(0,0,radius),(0,0,height-radius)]` — i.e. the CURRENT behavior becomes an explicit degenerate case of the new API, not a separate code path. This is the safer "integrate surgically" shape: `ResolveWithTransition`'s signature can add an optional `IReadOnlyList<(Vector3,float)>? sphereList = null` parameter that, when supplied, bypasses the scalar reconstruction. 2. **Replace the scalar overload's callers one at a time**, starting with the local player (Setup already resolved in `PlayerModeController.ApplyStepHeights` — the sphere list is one more DAT read away, `setup.Spheres`), then remote (Setup already resolved via `GetSetupCylinder`'s call site — same Setup object, just also read `.Spheres`), then ordinary. Either shape preserves the **captured replay fixtures**: for the human Setup, the 2-sphere reconstruction from `(0.48, 1.835)` is `(0,0,0.48)r=.48` + `(0,0,1.355)r=.48` — a **5 mm** head-center offset from the dat's actual `(0,0,1.350)`. Fixtures captured against the CURRENT scalar path will not bit-match a sphere-list port; TS-46's own register row already documents this 5 mm gap as the "residual." Re-baselining those fixtures is therefore an **expected, argued** re-baseline (the register row is the argument), not silent fixture drift — but it must be called out explicitly in the porting commit per the digest rule. ### 1.5 Where retail derives step-up/step-down height — THE KEY FINDING **FACT, fully resolved — acdream already has the exact retail mechanism, and the local-player half is already wired.** Retail chain: ``` OBJECTINFO::init (0x0050cf30, lines 274432-274463) this->step_up_height = CPhysicsObj::GetStepUpHeight(arg2) // 0x0050ea00 this->step_down_height = CPhysicsObj::GetStepDownHeight(arg2) // 0x0050ea20 CPhysicsObj::GetStepUpHeight (0x0050ea00, lines 276292-276302) if (this->part_array == 0) return; // (leaves object_info field untouched) return CPartArray::GetStepUpHeight(part_array); // TAILCALL CPartArray::GetStepUpHeight (0x005180d0, lines 286168-286179) if (this->setup == 0) return setup; // null return this->setup->step_up_height * this->scale; // BN mangled the FP // return into dead // stack-stores; the // arithmetic is a // scaled-by-object- // scale Setup field // read — see below. ``` (`CPartArray::GetStepDownHeight`, 0x005180f0, lines 286183-286194, is the identical shape for `setup->step_down_height`.) The **BN mush note**: the decompiled body literally prints ```c setup->step_up_height; this->scale; return setup; ``` i.e. it shows the two field reads as dead statements and returns the `CSetup*` pointer, because the real x87-register return value (`st0`) isn't tracked through the `__fastcall` tail the way BN's SSA expects. This is `feedback_bn_decomp_field_names` class 2 (x87-stack misread). The two adjacent field reads with no other use, immediately followed by a `return` of a pointer whose only remaining live use is as a "did we bail early" sentinel, is the standard BN shape for "return `a * b`" — I treat the multiplication as **INFERENCE** (very high confidence, matches the established pattern used identically at `GetStepDownHeight` and at `CPartArray::GetHeight`, line ~286153, which BN renders the SAME way for a confirmed-scaled field) but flag it as not 100%-certain without a Ghidra cross-check (currently unreachable) or a cdb dump of `st0` at `0x005180dd`/`0x005180fd`. Fallback/gate: `CTransition::step_up` (0x0050b610, lines 273099-273133) and the step-down poly-hit path (lines 273240-273338) both default `step_up_height`/`step_down_height` to the **literal `0.0399999991f` (0.04 m)**, and only read `object_info.step_up_height`/`step_down_height` (the Setup-derived value above) when `object_info.state & 2` is set (bit 0x2 — this is `ObjectInfoState.OnWalkable` in acdream's own enum, `src/AcDream.Core/Physics/TransitionTypes.cs:28`). There is also a `radius × 0.5` clamp in the poly-hit step-down branch (line 273840: `step_down_height = local_sphere->radius * 0.5f` when the initial `step_down_height` compare fails) — this clamp path was not fully traced in this pass (see §5). **acdream already ports this shape, mostly correctly:** - `ObjectInfo.StepUpHeight`/`StepDownHeight` fields exist (`src/AcDream.Core/Physics/TransitionTypes.cs:53-54`) with **defaults matching retail's literal fallback** (`0.01f` / `0.04f` — note: 0.01 not 0.04 for step-up; retail's step-up fallback constant was not independently found in this pass, only the step-down `0.0399999991f` at line 273109/273252 — flagged in §5). - **The local player already reads the real Setup values.** `src/AcDream.App/Input/PlayerModeController.cs:533-565` (`ApplyStepHeights`) resolves the player's own `Setup` DAT object and sets `controller.StepUpHeight = setup.StepUpHeight > 0 ? setup.StepUpHeight : 0.4f` (same for StepDown), i.e. **this is already the retail mechanism**, modulo two gaps: (a) the ×`this->scale` multiply from `CPartArray::GetStepUpHeight` is not applied (the raw dat field is used unscaled — a real but probably tiny divergence since player ObjScale is usually 1.0), and (b) the 0.4 fallback (not retail's 0.04) when Setup is absent — this 0.4 literal is a pre-existing acdream constant unrelated to retail's actual 0.04 fallback; likely a deliberate "coarser is safer" choice from before TS-46 was understood, not argued in a register row. - **The remote and ordinary paths do NOT read Setup at all** — both `RuntimeRemotePhysicsUpdater.cs:347-348` and (implicitly, via its `ResolveWithTransition` call) `RuntimeOrdinaryPhysicsUpdater.cs` hardcode `stepUpHeight: 0.4f, stepDownHeight: 0.4f` as literal constants at the call site, with an explicit comment at `RuntimeRemotePhysicsUpdater.cs:335-336`: *"stepUp/stepDown stay 0.4 (retail derives those from the Setup too — an adjacent divergence left as-is)"* — this is the acdream team's own existing acknowledgment of exactly this gap, already written down before this research pass. **Conclusion for P3.1's step-height sub-question:** the port is a **plumbing job, not a research job** — the retail formula, the acdream field shape, and even a correct reference implementation (the local player's `ApplyStepHeights`) already exist in the tree. The work is: (1) apply the same Setup-read-with-0.4-fallback pattern to the remote and ordinary callers (reusing the Setup object already fetched for `GetSetupCylinder`), and (2) decide whether to add the `× ObjScale` multiply that `CPartArray::GetStepUpHeight` performs and that the player path currently skips (recommend yes, for full parity, with a conformance test pinning a non-1.0-scale creature). --- ## 2. P3.2 — AD-25 + #165: remote collision response ### 2.1 Retail: `handle_all_collisions` is ONE function, used uniformly `CPhysicsObj::handle_all_collisions` (0x00514780, lines 282647-282760) is a plain `CPhysicsObj` member — there is no player-only or remote-only variant in retail. It has exactly two call sites, both inside `CPhysicsObj::SetPositionInternal` overloads: - Line 283524 (inside the single-arg `SetPositionInternal(CTransition*)` overload, itself called recursively from the full overload at line 283942): `handle_all_collisions(this, &edi->collision_info, transient_state & 1, transient_state & 2)` — i.e. **args 3/4 come from the object's own `transient_state` CONTACT_TS/ON_WALKABLE_TS bits BEFORE this call**, not a hardcoded before/after pair. - Line 283934 (inside `SetPositionInternal(Position*, SetPositionStruct*, CTransition*)`, the placement-failure branch): `handle_all_collisions(this, &edi->collision_info, 0, 0)` — forced "was not in contact, was not on walkable" when a placement attempt fails outright. Both call sites run for **any** `CPhysicsObj` — player, NPC, remote, door, prop. There is no retail fork by mover type. Body (already fully transcribed and analyzed in Core, see §2.2), the reflect-vs-suppress gate: ```c // var_10_1 (lines 282653-282657) — reflect UNLESS "prev grounded AND now // grounded AND not [some 0x20000 state bit]": var_10_1 = 1; if (arg4 != 0 && (transient_state & 2) != 0) var_10_1 = 0; if (arg4 == 0 || (transient_state&2)==0 || (this->state & 0x20000) != 0) var_10_1 = 1; // net: var_10_1 == 0 iff arg4!=0 && (ts&2)!=0 && (state&0x20000)==0 ``` Line 282656's literal shows as the string constant `"activation type (%s) with '%s' b…"` — a BN string-constant-folding artifact colliding with the actual bitmask; the REAL mask is confirmed by the later, unambiguous use at line 282701: `if ((this->state & 0x20000) == 0)` inside the same reflect-vs-zero branch. **FACT** (cross-confirmed by the unambiguous second read of the same bit): the gate is `shouldReflect = !(prevOnWalkable && nowOnWalkable && !bit0x20000)`. `0x20000` on `CPhysicsObj.state` (not `ObjectInfo.state` — a different bitfield) is very likely `PhysicsStateFlags.Sledding` by elimination (it is the only state bit acdream's own port names in this exact role — see `PhysicsObjUpdate.HandleAllCollisions`'s `sledding` variable below) but this specific bit-value↔name mapping was **not independently re-derived from `acclient.h`** in this pass — **INFERENCE**, high confidence (acdream's own prior port already made this identification and it is internally consistent with #166's description of sledding needing an ALWAYS-reflect path), flagged for a cheap confirmation grep against `acclient.h`'s `PhysicsState` enum in the porting session. ### 2.2 acdream already has a verbatim, correct port `src/AcDream.Core/Physics/PhysicsObjUpdate.cs:152-191` (`PhysicsObjUpdate.HandleAllCollisions`) is a **line-for-line correct port** of the above: ```csharp bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding); bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding); if (body.FramesStationaryFall <= 1) { if (shouldReflect && collisionNormalValid) { if (Inelastic) body.Velocity = Vector3.Zero; else { /* elastic reflect: v += -(dot(v,n))*(e+1)*n when dot<0 */ } } } else { body.Velocity = Vector3.Zero; } // fsf>1 bleed ``` This is already used for the **local player AND every "ordinary" body** (non-remote-DR, non-projectile) via `RuntimeOrdinaryPhysicsUpdater.cs:150` → `PhysicsObjUpdate.CommitSetPositionTransition` → `HandleAllCollisions` (the `CommitSetPositionTransition` wrapper additionally handles the HitGround/LeaveGround callback ordering retail interleaves at `SetPositionInternal` lines 283490-283510 — also verbatim-ported). **The remote dead-reckoning sweep (`RuntimeRemotePhysicsUpdater.cs:432-462`) does NOT call this function.** It has its own hand-inlined reflect block with a DIFFERENT, narrower gate: ```csharp // RuntimeRemotePhysicsUpdater.cs:436-439 (current) bool applyBounce = sledding ? !(prevOnWalkable && nowOnWalkable) // WRONG when sledding — see below : (!prevOnWalkable && !nowOnWalkable); // WRONG in general — see below ``` **FACT — this is provably narrower than retail in two ways:** 1. **Sledding case.** Retail: `shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding)` — when `sledding` is true, the `!sledding` term is `false`, so the whole AND collapses to `false` regardless of grounded state, so `shouldReflect = true` **unconditionally**. Retail's sledding mover ALWAYS runs the reflect/elastic-bounce math — this is the mechanism that produces the "sled" glide-and-bounce feel (#166). The remote code's sledding branch (`!(prevOnWalkable && nowOnWalkable)`) instead SUPPRESSES the bounce whenever grounded-before-and-after, which is the opposite of what sledding is supposed to do. 2. **Non-sledding case.** Retail: `shouldReflect = !(prevOnWalkable && nowOnWalkable)` — reflects on every transition EXCEPT grounded→grounded (airborne→airborne, airborne→grounded, and grounded→airborne all reflect). The remote code's non-sledding branch (`!prevOnWalkable && !nowOnWalkable`) only reflects on airborne→airborne, explicitly suppressing reflection on the grounded↔airborne transitions retail DOES reflect on. This is exactly what the register row AD-25 describes ("still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding") — this research pass confirms it with the actual formula-level diff, not just the register's prose. ### 2.3 Port sketch for AD-25 Minimal, surgical fix: delete the hand-inlined block at `RuntimeRemotePhysicsUpdater.cs:432-462` and replace the call with `PhysicsObjUpdate.HandleAllCollisions(rm.Body, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, prevContact, prevOnWalkable, nowOnWalkable)`, computing `prevContact`/`prevOnWalkable` the same way the existing block already does (`rm.Body.OnWalkable` before the resolve) and `nowOnWalkable = resolveResult.IsOnGround` (already computed). `PhysicsObjUpdate` requires `body.FramesStationaryFall` — this field already exists on `PhysicsBody` (shared class with the local-player path) so no new state is needed. **Open judgment call for the porting agent, not resolved here:** whether to route through the higher-level `PhysicsObjUpdate.CommitSetPositionTransition` (which also owns HitGround/LeaveGround dispatch order) instead of the raw `HandleAllCollisions`, given the remote path already has its own bespoke landing-detection block (lines 464-537, with interp-queue-clear and animation-hook-specific logic not present in the ordinary path). Given the CLAUDE.md "integrate surgically... change the MINIMUM necessary" rule, the narrower `HandleAllCollisions`-only swap is the safer first cut; folding in `CommitSetPositionTransition` is a larger, separately-arguable refactor. ### 2.4 #165 — remote wall penetration: FACT vs HYPOTHESIS **FACT (retail mechanism, `PositionManager::adjust_offset` 0x00555190 → `InterpolationManager::adjust_offset` 0x00555d30, lines 353071-353257):** the catch-up step is a `Position::subtract2`-derived delta Frame, magnitude- clamped per tick to `catchUpSpeed × dt` where `catchUpSpeed = max_speed(or adjusted_max_speed) × MAX_INTERPOLATED_VELOCITY_MOD(2.0)`, with a fallback constant `MAX_INTERPOLATED_VELOCITY = 7.5` (line 353137, `0x40f00000`) when no minterp/max-speed is available. There is also a 5-frame stall window (`frame_counter >= 5`) that can raise `node_fail_counter`, and a hard **unclamped** snap when `node_fail_counter > 3` (`InterpolationManager::UseTime`, 0x00555f20 — "blip to tail/head", lines 353261+): the delta becomes the FULL remaining distance to the target with no speed cap at all. **FACT (acdream's port):** `src/AcDream.Core/Physics/InterpolationManager.cs` is a documented, careful, near-verbatim port of the above (constants `MaxInterpolatedVelocityMod = 2.0f`, `MaxInterpolatedVelocity = 7.5f`, `StallCheckFrameInterval = 5`, `StallFailCountThreshold = 3` all cite the exact retail addresses/lines). `ComputeStep` (lines 372-482) clamps the per-tick step to `min(catchUp × dt, dist)` (line 470-474) in the normal case, and produces the SAME unclamped "tail-delta" snap (`_failCount > StallFailCountThreshold`, lines 458-467) that retail does. **INFERENCE / open — not confirmed in this pass:** whether the *observed* #165 symptom ("swallowed a bit by the wall") traces to: - (a) the **unclamped stall-fail snap** committing a position on the far side of / inside a wall in one tick, which is then fed as `preIntegratePos → postIntegratePos` into `ResolveWithTransition`'s sweep — the sweep SHOULD still catch a wall crossing between two arbitrary points (it's a normal sphere sweep, not distance-limited), so this would only tunnel if the sweep itself has some other gap; or - (b) a **one-frame skip of the sweep entirely** — `RuntimeRemotePhysicsUpdater.cs:320` gates the whole `ResolveWithTransition` call on `rm.CellId != 0 && LandblockCount > 0`, with a comment calling this "a one-frame grace until the first UP arrives" — if this grace condition is reachable on ANY tick other than first-spawn (e.g. transiently during a landblock/cell transition), the raw catch-up position commits unswept for that tick, which would look exactly like "swallowed a bit" before the NEXT tick's sweep clamps it back; or - (c) render/interpolation smoothing on the App side displaying an intermediate frame ahead of the collision-corrected position (a presentation lag, not a physics tunnel) — not investigated in this pass at all (out of Core/Runtime scope, would need an App-layer render-frame read). None of (a)/(b)/(c) is confirmed; I did not find a smoking gun equivalent to §1.5's or §2.1-2.2's clean formula diff. This matches the ISSUES.md #165 entry's own framing (three ranked candidates, "capture first"). **Diagnostic recommendation (reusing existing apparatus, per the apparatus-before-3rd-attempt rule):** `ACDREAM_PROBE_RESOLVE=1` filtered to the specific remote's guid at the moment of wall contact will show, per resolve call, whether the resolver reports `Collided`-with-position-still- inside-geometry (implicates the sweep/BSP itself — candidate outside this research's scope, would reopen a #98-class investigation) versus a resolve that never fired at all that tick (implicates candidate (b), the grace-skip condition) versus a resolve whose INPUT `preIntegratePos` is already inside the wall (implicates candidate (a), the unclamped stall snap feeding a bad start point). `ACDREAM_CAPTURE_RESOLVE=` would let this be replayed offline against the trajectory-replay harness. Both are existing tools; no new instrumentation is needed to start. --- ## 3. P3.3 — TS-23: PK/PKLite/Impenetrable mover bits ### 3.1 Retail bit values — `OBJECTINFO::init` (0x0050cf30, lines 274432-274463) ```c void OBJECTINFO::init(OBJECTINFO* this, CPhysicsObj* obj, int32_t state) { this->object = obj; this->state = state; this->scale = obj->m_scale; this->step_up_height = CPhysicsObj::GetStepUpHeight(obj); // see §1.5 this->step_down_height = CPhysicsObj::GetStepDownHeight(obj); this->ethereal = (obj->state & 4); this->step_down = !(obj->state >> 6) & 1; CWeenieObject* w = obj->weenie_obj; if (w != 0) { if (w->vtable->IsImpenetrable()) this->state |= 0x80; // IsImpenetrable if (w->vtable->IsPlayer()) this->state |= 0x100; // IsPlayer if (w->vtable->IsPK()) this->state |= 0x800; // IsPK if (w->vtable->IsPKLite()) this->state |= 0x1000; // IsPKLite } } ``` **FACT.** This exactly matches acdream's `ObjectInfoState` enum (`src/AcDream.Core/Physics/TransitionTypes.cs:24-44`): `IsImpenetrable = 0x080`, `IsPlayer = 0x100`, `EdgeSlide = 0x200` (retail sets EdgeSlide elsewhere, not in this function — acdream's own comment at `RuntimeOrdinaryPhysicsUpdater.cs:138` etc. treats it as bundled with IsPlayer, a pre-existing simplification not re-litigated here), `IsPK = 0x800`, `IsPKLite = 0x1000` — the enum already carries a doc comment citing `acclient_2013_pseudo_c.txt:276807-276839` (the `FindObjCollisions` PvP exemption block) for these exact values, so this is not new discovery — it CONFIRMS the enum values already in the tree are correct. ### 3.2 acdream already parses this data — it is just not plumbed onto the mover Three layers already exist, fully wired, verified by reading the current files: 1. **Wire parse.** `src/AcDream.Core.Net\Messages\CreateObject.cs:814-819` reads `objectDescriptionFlags = ReadU32(...)` — the `PublicWeenieDesc._bitfield` trailer field — with a comment explicitly naming `BF_PLAYER_KILLER (0x20)`, `BF_FREE_PKSTATUS (0x200000)`, `BF_PKLITE_PKSTATUS (0x2000000)` as "read for `IsPK()`/`IsPKLite()`/`IsImpenetrable()`... previously discarded; now surfaced." 2. **Decode.** `src/AcDream.Core/Physics/EntityCollisionFlags.cs` — `EntityCollisionFlagsExt.FromPwdBitfield(uint bitfield)` decodes `IsPlayer|IsPK|IsImpenetrable|IsPKLite` from the raw PWD bitfield (bit values `0x8`/`0x20`/`0x200000`/`0x2000000` respectively — the PWD wire bitfield's own numbering, DISTINCT from `ObjectInfoState`'s numbering; these are two different bit-spaces that must not be confused). 3. **Storage — per-GUID, not target-only.** `src/AcDream.Core/Items/ClientObjectTable.cs:813` and `ClientObject.cs:230/325` store `PublicWeenieBitfield` on **every** `ClientObject` row, keyed by GUID — this includes the local player's own row and every remote's own row, not just "targets" of someone else's collision check. `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:318-320` already demonstrates the exact lookup pattern needed: `_domain.EntityObjects.Objects.Get(guid)?.PublicWeenieBitfield`. 4. **Consumer today — target-side only.** `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:151-155` builds `EntityCollisionFlags` (via `FromPwdBitfield`) for every registered live entity's SHADOW registration — this is the data OTHER movers collide against (the `targetFlags` half of `CollisionExemption`, `src/AcDream.Core/Physics/CollisionExemption.cs:95-126`). The exemption code already correctly checks `moverState & ObjectInfoState.IsPK` against `targetFlags & EntityCollisionFlags.IsPK` (lines 108-113) — the LOGIC is complete and correct; only the `moverState` INPUT is wrong. **The actual gap, confirmed at all four current call sites** (grep-exhaustive in this pass, `select:` on `ObjectInfoState.IsPlayer \|`): | Site | File:line | Current `moverFlags` | |---|---|---| | Local player, grounded/ordinary | `PlayerMovementController.cs:1382` | `ObjectInfoState.IsPlayer \| ObjectInfoState.EdgeSlide` (hardcoded) | | Local player, second call site | `PlayerMovementController.cs:1822` | `ObjectInfoState.IsPlayer` (hardcoded, no EdgeSlide) | | Remote DR sweep | `RuntimeRemotePhysicsUpdater.cs:372` | `IsPlayerGuid(serverGuid) ? IsPlayer\|EdgeSlide : EdgeSlide` | | Remote DR sweep, 2nd site | `RuntimeRemotePhysicsUpdater.cs:707` | same pattern | | Ordinary updater | `RuntimeOrdinaryPhysicsUpdater.cs:138` | `IsPlayerGuid(record.ServerGuid) ? IsPlayer\|EdgeSlide : EdgeSlide` | | Remote teleport | `RemoteTeleportController.cs:290` | `IsPlayer\|EdgeSlide : ...` (non-player branch not read in this pass) | `IsPlayerGuid(guid) => (guid & 0xFF000000u) == 0x50000000u` (a cheap GUID- prefix heuristic, unrelated to the wire bitfield) is the ONLY player detection in play at these sites — none of them consult `PublicWeenieBitfield`/`FromPwdBitfield` at all. The `RuntimeRemotePhysicsUpdater.cs:358-371` comment block ALREADY documents this exact gap in the team's own words before this research pass: *"PK/ PKLite/Impenetrable are NOT plumbed onto the remote mover yet, so a PK pair walks through where retail collides — the SAME M1.5 gap the local player carries (see TS-23...)."* ### 3.3 Plumb path At each site: resolve the mover's own `ClientObject` via the already-owned `ClientObjectTable` accessor (per J3.4, `RuntimeEntityObjectLifetime` owns the canonical table; `PlayerMovementController`/`RuntimeRemotePhysicsUpdater`/ `RuntimeOrdinaryPhysicsUpdater` are all `AcDream.Runtime` classes, which CAN reference `AcDream.Core.Items.ClientObjectTable` since Runtime depends on Core, not the reverse — no new project reference needed), read `.PublicWeenieBitfield`, decode via `EntityCollisionFlagsExt.FromPwdBitfield` (already exists), then translate the RESULT's `EntityCollisionFlags` bits into `ObjectInfoState` bits (they are numerically different bit-spaces — `EntityCollisionFlags.IsPK = 0x04` vs `ObjectInfoState.IsPK = 0x800` — this translation is a new 3-line helper, not existing code) and OR them into the `moverFlags` argument already being constructed at each site. **Exact wiring at each of the 5-6 call sites (constructor/field access for `ClientObjectTable` at each class) was not traced in this pass** — the `AcDream.Runtime` classes listed do not currently hold a `ClientObjectTable` reference (confirmed by grep: `RuntimeInventoryState.cs`, `RuntimeHostileTargetQuery.cs`, `RuntimeEntityObjectLifetime.cs`, `RuntimeEntityObjectViews.cs`, `RuntimeEntityObjectEventStream.cs`, and `LiveSessionEventRouter.cs` are the only Runtime files referencing it today — `PlayerMovementController`/`RuntimeRemotePhysicsUpdater`/ `RuntimeOrdinaryPhysicsUpdater` are NOT in that list). The porting agent will need to either inject the table (constructor param) or add a small read-only accessor delegate (matching the existing `IsPlayerGuid`-style static-predicate pattern already used at these sites) — a scoped DI/plumbing decision, not a research question. ### 3.4 Non-PK invariant test (explicitly required by the plan) The plan requires "non-PK ACE behavior must be provably unchanged." Given §3.1's decode: a `ClientObject` with `PublicWeenieBitfield == null` (never received, e.g. a non-player prop) or with none of bits `0x20/0x200000/0x2000000` set decodes to `EntityCollisionFlags.None` for the PK/PKLite/Impenetrable bits, which translates to `ObjectInfoState.None` for those bits — i.e. the OR is a no-op and `moverFlags` is bit-identical to today's hardcoded value for any non-PK, non-PKLite, non-Impenetrable mover (which is every mover in ACE's default character-creation state, per the existing TS-23 register row's own text). The invariant test: construct movers with `PublicWeenieBitfield = null` and `PublicWeenieBitfield = 0` and assert `ResolveWithTransition`'s effective `moverFlags` is unchanged from the pre-port value in both cases; a second test sets the PK bit on a `ClientObjectTable` row and asserts the walk-through exemption in `CollisionExemption` (already-correct logic, §3.2 point 4) now actually fires for a PK-vs-PK pair and does NOT fire for a PK-vs-non-PK pair — this second test is the actual acceptance criterion for TS-23, not just the non-regression guard. --- ## 4. P4.1 — AP-71: `check_entry_restrictions` ### 4.1 Retail: `CObjCell::check_entry_restrictions` (0x0052b6d0, lines 308873-308912) ```c TransitionState CObjCell::check_entry_restrictions(CObjCell* this, CTransition* t) { CPhysicsObj* mover = t->object_info.object; if (mover != 0) { CWeenieObject* moverWeenie = mover->weenie_obj; if (moverWeenie == 0) return OK_TS; // no weenie → pass int32_t canBypass = moverWeenie->vtable->CanBypassMoveRestrictions(); if ((t->object_info.state & 0x100) == 0) // NOT IsPlayer return OK_TS; // NPCs bypass entirely uint32_t restrictionObj = this->restriction_obj; if (restrictionObj == 0 || canBypass) return OK_TS; CPhysicsObj* rObj = CPhysicsObj::GetObjectA(restrictionObj); if (rObj != 0) { CWeenieObject* rWeenie = rObj->weenie_obj; if (rWeenie != 0) { if (rWeenie->vtable->CanMoveInto(moverWeenie) != 0) return OK_TS; this->vtable->handle_move_restriction(t); // side effect (msg/anim) } } } return COLLIDED_TS; // (2) } ``` **FACT, confirmed doubly:** this gate is called from BOTH the outdoor and indoor collision entry points, at the exact same position (first thing in the function, before any BSP work): - `CEnvCell::find_env_collisions` (0x0052c130, line 309576): `check_entry_restrictions(this, arg2)` is the first statement. - `CLandCell::find_env_collisions` (0x00532f20, line 317075): `if (check_entry_restrictions(this, ebp) != OK_TS) return;` — same pattern, one function earlier in the call than I initially expected (AP-71's register row cites only the EnvCell/indoor site; **this research additionally confirms the outdoor `CLandCell` site uses the identical gate** — a scope expansion the porting agent should know about: a restricted OUTDOOR cell, if that's ever content-modeled, is gated the same way). **FACT — only PLAYERS are gated.** The `(state & 0x100) == 0 → OK_TS` early return means NPCs/monsters/props bypass this check entirely regardless of `restriction_obj`. `ObjectInfoState.IsPlayer = 0x100` matches acdream's existing enum exactly (§3.1). ### 4.2 `CanBypassMoveRestrictions` / `CanMoveInto` — the house-guest mechanism `ACCWeenieObject::CanBypassMoveRestrictions` (0x0058c500, lines 406605-406614): ```c bool CanBypassMoveRestrictions(ACCWeenieObject* this) { uint32_t bf = this->pwd._bitfield; return (bf & 0x100000) != 0 && (bf & 0x400000) != 0; } ``` **FACT.** Two PWD bitfield bits ANDed together (both must be set) — the register row cites these same two bit values (0x100000/0x400000); this research confirms the exact AND-combination logic, which was not previously spelled out. `ACCWeenieObject::CanMoveInto` (0x0058da40, lines 407982-408056): ```c bool CanMoveInto(ACCWeenieObject* this, CWeenieObject* mover) { if (mover == 0) return false; // conservative if (this->pwd._house_owner_iid == 0 || this->pwd._house_owner_iid == mover->id) return true; // owner always in RestrictionDB* db = this->pwd._db; if (db == 0) return true; // no list → open if (RestrictionDB::IsAllowedIn(db, mover->id, mover->allegiance_or_similar_at_0x128)) return true; // ... not allowed: PlayScript(pscript, ...) visual/audio denial cue return false; } ``` **FACT.** This IS the AC house-guest/lock mechanism — `pwd._house_owner_iid` (the house owner's instance ID) and `pwd._db` (a `RestrictionDB*` — presumably the guest/ban list) live on the RESTRICTION OBJECT's own weenie (the object pointed at by `this->restriction_obj`, resolved via `CPhysicsObj::GetObjectA`), not on the cell or the mover. ### 4.3 OPEN QUESTION — where `restriction_obj`'s value comes from (not resolved; Ghidra MCP down) `CObjCell::check_entry_restrictions` reads `this->restriction_obj` as a **uint32 object IID**, resolved via `CPhysicsObj::GetObjectA(restriction_obj)` to find a live weenie. Tracing where `restriction_obj` is ASSIGNED, I found exactly one write site: `CEnvCell::UnPack` (0x0052d470, line 310871): ```c this->restriction_obj = (uint32_t)ecx_5; // a single BYTE read from the // cell's DAT unpack stream ``` immediately followed (line 310912) by using **the same field** as a COUNT to allocate an array: `operator new[]((restriction_obj * 0x18) + 4)` — i.e. in `UnPack`, this looks like a small integer count (0-255, since it's an 8-bit read) driving allocation of `restriction_obj` structures of 0x18 (24) bytes each, not a 32-bit object GUID. **These two uses are inconsistent** — a per-cell field that is (a) read as a small DAT-baked count used to size an array during static cell UnPack, and (b) read as a live 32-bit object IID during collision-gate evaluation, cannot both be the literal same struct member unless CEnvCell overwrites it at RUNTIME between DAT load and gameplay (e.g. a live server association replacing the static field after house/lock data arrives over the wire). This is exactly the class of ambiguity `feedback_bn_decomp_field_names` warns about — BN's heuristic field-name propagation can attach the SAME auto-generated name to two DIFFERENT actual struct offsets it failed to disambiguate across two different functions. **Wire-side FACT, for context (not a resolution):** acdream's own `GameEventType.cs` already enumerates `HouseData = 0x0225`, `HouseStatus = 0x0226`, `HouseUpdateRestrictions = 0x0248` (present in the codebase before this research pass — i.e. someone already knew these opcodes exist), and `references/Chorizite.ACProtocol/.../protocol.xml` additionally lists an `ObjectDescriptionFlag` bit `HouseRestrictions = 0x4000000` (line 467) and a large family of house-guest opcodes (`House_AddPermanentGuest 0x0245`, `House_RemovePermanentGuest 0x0246`, `House_RequestFullGuestList 0x024D`, etc.). None of this is currently PARSED into any acdream data structure (grep-confirmed: zero non-enum matches for `HouseData`/`ObjectDescriptionFlag.HouseRestrictions` in `src/`). `references/ACE/Source/ACE.Server/WorldObjects/House.cs` confirms ACE does model a full server-side house/guest/monarch system, so this is NOT provably inert against every possible local-ACE test scenario (a player-built house IS creatable), even though the register row's claim that it's inert against the ACE **starter area** specifically stands. **What I could NOT determine in this pass, and recommend as the next concrete step:** whether `CObjCell.restriction_obj`'s *live* value (the one `check_entry_restrictions` reads) is populated (a) from a completely different, mis-attributed static DAT field than the one at `UnPack:310871`, or (b) dynamically overwritten at runtime when a house-linked cell/portal weenie spawns nearby, and if (b), which of the House_* wire messages triggers that write. This needs either (i) Ghidra's independent decompiler (down at research time — retry when back up, look specifically at `CObjCell`'s struct layout / xrefs to the `restriction_obj` offset), or (ii) an ACE-side or holtburger-side house-cell association read (not checked in this pass — `references/ACE` does model houses but I did not search for where ACE associates a *cell* with a house's restriction data specifically), or (iii) a live cdb capture of `this->restriction_obj` at `check_entry_restrictions`'s entry (0x0052b6d5) while standing at a real ACE-created house's locked door, per the retail debugger toolchain in CLAUDE.md. ### 4.4 Port pseudocode (structure only — value-source is the open question above) ```csharp // CObjCell.check_entry_restrictions port sketch TransitionState CheckEntryRestrictions(CTransition t) { var mover = t.ObjectInfo.Object; // the live ClientObject/weenie, if any if (mover is null) return TransitionState.OK; if (!t.ObjectInfo.IsPlayer) return TransitionState.OK; // NPCs bypass if (mover.CanBypassMoveRestrictions()) return TransitionState.OK; // §4.2 two-bit AND uint restrictionObjGuid = this.RestrictionObj; // §4.3 — SOURCE UNRESOLVED if (restrictionObjGuid == 0) return TransitionState.OK; var restrictionWeenie = ResolveWeenie(restrictionObjGuid); if (restrictionWeenie is { } rw) { if (rw.CanMoveInto(mover)) return TransitionState.OK; // §4.2 owner/db check HandleMoveRestriction(t); // client-side denial cue (message/sound) } return TransitionState.Collided; } ``` **Minimal restriction state acdream must track, once §4.3 resolves:** a per-cell nullable `restriction_obj` GUID field (on whatever acdream's `CObjCell`-equivalent is — `CellPhysics`, `src/AcDream.Core/Physics/PhysicsDataCache.cs:540`, confirmed by the AP-71 register row to currently have NO such field), plus enough of the target weenie's data to answer `CanBypassMoveRestrictions` (two PWD bitfield bits — already parsed per §3.2, just need the two extra bit values `0x100000`/`0x400000` added to `EntityCollisionFlagsExt`) and `CanMoveInto` (house owner IID + an allow/ban list — NOT currently modeled anywhere in acdream; this is new state, likely fed by `HouseData`/`HouseUpdateRestrictions` if §4.3 resolves to the wire-dynamic answer). --- ## 5. P4.2 — AP-10 + water semantics ### 5.1 The formula is ALREADY verbatim-correct in acdream; only the source constant is collapsed **FACT, cross-confirmed via ACE's clean C# port (BN mush avoided entirely for this item since ACE's port is unambiguous):** `references/ACE/Source/ACE.Server/Physics/ObjectInfo.cs:101-171` (`ObjectInfo.ValidateWalkable`) — the non-viewer branch, line 124: ```csharp var dist = Vector3.Dot(checkPos.Center - new Vector3(0, 0, checkPos.Radius), contactPlane.Normal) + contactPlane.D + waterDepth; if (dist >= -PhysicsGlobals.EPSILON) { /* touching/above → OK, maybe SetContactPlane */ } else { /* below → push up by zDist = dist / normal.Z, Adjusted */ } ``` `waterDepth` is added DIRECTLY into the signed plane-distance before the `±EPSILON` compare — a positive `waterDepth` lets the sphere's true geometric bottom sit up to `waterDepth` meters below the plane while `dist` still reads "touching," so `SetContactPlane`/`OnWalkable` still commit. **acdream's own `ValidateWalkable`** (`src/AcDream.Core/Physics/TransitionTypes.cs:3068-3146`) is a **byte-for-byte structural match**, already citing "ACE `ObjectInfo.ValidateWalkable()` line 124" in its own doc comment (line 3082) — i.e. this formula was already correctly ported before this research pass. Line 3089: `float dist = Vector3.Dot(lowPoint, contactPlane.Normal) + contactPlane.D + waterDepth;`. **The ONLY collapsed piece is the VALUE fed into `waterDepth`.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs:538-559`) returns `0.9f` for entirely-water cells (matches retail's visual full- submersion, unchanged), `0.45f` for a partially-water cell's water corner (unchanged), but returns **`0f`** for a partially-water cell's DRY corner where retail's own comment (already in the file, `TerrainSurface.cs:522-528`) says retail returns **`0.1f`**. I traced `CObjCell::get_water_depth` (0x0052b8a0, lines 309007-309032) and its sole caller `CLandCell::find_env_collisions` (0x00532fd5, confirmed by line 317094: `float depth = get_water_depth(this, &var_28)`, fed into `validate_walkable`'s explicit float argument at line 317111) — this confirms the STRUCTURAL fact that water depth flows `get_water_depth → validate_walkable` as an argument (matching acdream's own architecture), but `get_water_depth` itself (lines 309007-309032) delegates to `CLandBlockStruct::calc_water_depth` (0x005315f0, lines 315578-315644) which returns a **`TERRAIN_SURF_CHAR[...]` table lookup** (a small integer, 0-31 range, surface-TYPE code) — **not** a floating-point 0.1 literal. I could NOT find the exact site inside `validate_walkable`'s own body (0x0050d010, lines 274479-274617) where a `0.1f` water-specific constant is applied to `arg5` (the water-depth parameter) — the ONE `-0.1f`-shaped literal I found in that function (line 274593, `-0.10000000000000001`) is, per ACE's unambiguous cross-reference (`ObjectInfo.cs:154`, `interp < -0.1f`), **the step-down `WalkInterp` bound, unrelated to water** — I flag this explicitly because I initially suspected it was the water constant and the ACE cross-check disproved it; recording this here so a future pass doesn't repeat the same false match. **Conclusion:** the `0.1f` retail constant's exact origin (is it a second hardcoded literal inside `validate_walkable` gated on `isWater`, or is `calc_water_depth`'s `TERRAIN_SURF_CHAR` table itself scaled by some factor that produces 0.1 for a specific surface-type code, or is it purely an ACE- side reverse-engineered constant with no direct 1:1 retail literal at all) is an **open question** — but it does not block the fix: acdream's own architecture already matches ACE's `ObjectInfo.ValidateWalkable` formula exactly, ACE's C# source is unambiguous that the dry-corner value is a constant fed as `waterDepth`, and restoring `TerrainSurface.SampleWaterDepth`'s dry-corner return from `0f` to `0.1f` is a 1-line, format-matching change regardless of which retail literal ultimately produced 0.1. ### 5.2 The "restore risk" is likely a DIFFERENT, adjacent bug — do not re-collapse as a second workaround The existing acdream comment justifying the 0→0.1 collapse (`TerrainSurface.cs:522-528`) says a nonzero dry-corner value "destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires...)." **This is structurally true of retail too, by design, not a bug the collapse is protecting against.** Looking at `ValidateWalkable`'s "above or touching" branch (both retail/ACE and acdream, §5.1): when `dist > EPSILON` (strictly above the touching band — which a `+0.1` waterDepth shift WILL produce for a character standing statically on dry ground with a near-exact geometric contact), the function returns OK **without** calling `SetContactPlane`/committing walkable state THIS call — in ALL THREE implementations (retail, ACE, acdream), water or not. Retail relies on `Contact`/`OnWalkable` being STICKY bits that persist across frames where a given call skips the touch reassertion, not on every single call re-asserting them. If restoring 0.1 reproduces the described "floats/falls every frame" symptom, the likely root cause is that some OTHER acdream code path clears `Contact`/`OnWalkable` unconditionally each tick (unlike retail's sticky-bit model), and `ValidateWalkable`'s water-shifted skip then never gets a chance to reassert — i.e. **the real bug, if it still exists, is in whatever clears the sticky bits, not in the water-depth term itself.** Per CLAUDE.md's no-workarounds rule, this makes AP-10 potentially a two-step port: (1) restore the `0.1f` dry-corner constant, (2) run the existing walkable/terrain-edge regression suite plus a manual walk-on-shoreline gate; if the float/fall symptom reproduces, the follow-up is root-causing the sticky-bit clearing, not re-collapsing to 0 a second time. This is a testable prediction for the porting agent, not a claim that the fix is risk-free. ### 5.3 `WATER_CONTACT_TS` (bit 0x8) — a genuinely unported piece, found in this pass `acclient.h:3688-3700` — `TransientState` enum: `WATER_CONTACT_TS = 0x8` (bit 3), alongside `CONTACT_TS = 0x1`, `ON_WALKABLE_TS = 0x2`. **FACT — retail writes this bit in `CPhysicsObj::SetPositionInternal`** (0x005153e5-0051545f, lines 283459-283483), in the SAME statement block that writes `CONTACT_TS`, immediately after it: ```c int32_t contactPlaneIsWater = arg2->collision_info.contact_plane_is_water; // ... this->transient_state = (contact_plane_valid) ? (ts|1) : (ts & ~1); // CONTACT_TS CPhysicsObj::calc_acceleration(this); // then, unconditionally right after: this->transient_state = contactPlaneIsWater ? (this->transient_state | 8) // WATER_CONTACT_TS : (this->transient_state & ~8); ``` This is a **persistent, object-level transient-state bit**, mirroring the per-resolve local `CollisionInfo.ContactPlaneIsWater` bool onto the body's own sticky state — exactly parallel to how `CONTACT_TS`/`ON_WALKABLE_TS` are promoted from the resolve's local result onto `transient_state`. **FACT — acdream has NOT ported this promotion.** `src/AcDream.Core/Physics/PhysicsBody.cs:99-103`: ```csharp // Declared to complete retail's TransientState (acclient.h:3688). Neither bit is // produced or consumed by acdream's transition yet; they are here so the two free // slots cannot be reused for something else and quietly collide with the wire. WaterContact = 0x00000008, // bit 3 — WATER_CONTACT_TS CheckEthereal = 0x00000100, // bit 8 — CHECK_ETHEREAL_TS ``` The bit is DECLARED (reserved, so nothing else accidentally claims it) but never WRITTEN — `ContactPlaneIsWater` is extensively threaded through `CollisionInfo` and `PhysicsBody.ContactPlaneIsWater` (dozens of call sites, grep-confirmed) as a plain bool, but it is never mirrored into `PhysicsBody.TransientState`'s `WaterContact` flag the way `Contact`/ `OnWalkable` already are (via `PhysicsObjUpdate.ApplySetPositionContact`/ `CommitSetPositionTransition`, `src/AcDream.Core/Physics/PhysicsObjUpdate.cs:30-127` — neither of these currently touches `WaterContact`). **Port sketch:** add one line to `PhysicsObjUpdate.ApplySetPositionContact` and `CommitSetPositionTransition`, immediately alongside the existing `Contact` bit write, mirroring `inContact` → `TransientStateFlags.Contact`: ```csharp if (body.ContactPlaneIsWater) body.TransientState |= TransientStateFlags.WaterContact; else body.TransientState &= ~TransientStateFlags.WaterContact; ``` This is a small, low-risk, purely-additive change (the bit is currently inert, so writing it cannot regress anything that reads it, because nothing reads it). **OPEN — who CONSUMES `WATER_CONTACT_TS` after it's set, in retail?** Not found in this pass. I did not do an exhaustive scan for reads of bit 0x8 on `transient_state` elsewhere in the 1.4M-line pseudo-C dump (the write site was found via the `contact_plane_is_water` local-variable trail, which does not generalize to finding reads of the resulting bit, since bit-mask reads are not text-greppable without high false-positive noise across unrelated 0x8 masks). **This is the one genuinely unresolved scope question for AP-10:** is `WATER_CONTACT_TS` purely a reporting/query bit (e.g. read by some "is player swimming" query for animation/sound/UI, with no gameplay- feel consequence), or does something in the movement/friction/step chain branch on it? Recommend a follow-up grep pass (or Ghidra MCP xref query `/function_xrefs?name=CPhysicsObj::SetPositionInternal` once reachable) as the next concrete step before claiming "water semantics" fully verified. ### 5.4 Other water-behavior divergences found (facts only, no scope invention) Beyond the AP-10 constant and the WATER_CONTACT_TS gap above, this pass found no additional confirmed water-specific divergences. Specifically checked and found NO evidence of divergence in: - The `CLandCell::find_env_collisions` ENTIRELY_WATER early-exit (line 317091: `if (block_water_type == ENTIRELY_WATER && !ethereal && !(state&0x40)) return;` — a swimming/ethereal exemption from terrain collision entirely) — acdream's handling of this specific branch was **not cross-checked** against this exact condition in this pass; flagged as unverified rather than asserted-divergent or asserted-matching. - Jump-in-water and movement-effects-in-water (e.g. reduced jump height, swim animation triggers) — **not investigated**, out of the physics/ collision scope this pass focused on; would require a `MovementSystem`/ animation-side read not attempted here. --- ## 6. Port order + blast radius ### P3 (TS-46, AD-25, #165, TS-23) **Recommended order:** TS-23 (§3) first — it is pure plumbing against already-correct logic and already-correct data, lowest risk, and its own non-regression test (§3.4) is cheap to write. Then AD-25 (§2.1-2.3) — a one-function-call swap with an existing verbatim replacement, second-lowest risk. TS-46 (§1) third — touches the collision CAPSULE of every mover, the highest-blast-radius item in this batch; do it after the other two so any regression is attributable. #165 (§2.4) last, and only after AD-25 lands (the remote reflect fix changes remote velocity behavior at walls, which could itself mask or unmask the wall-penetration symptom — diagnose #165 fresh, post-AD-25, not against pre-AD-25 captures). **Fixtures/tests to capture BEFORE touching TS-46:** - `SphereCollisionFamilyTests` and `WindowOpening_HeadCannotFit_EntryBlocked` (cited in the digest as TS-46-adjacent pins) — re-run and record baseline pass/fail before the sphere-list migration. - Any `ACDREAM_CAPTURE_RESOLVE` fixtures currently pinned to the human Setup's 2-scalar reconstruction (0.48/1.835) — these WILL shift by ~5 mm at the head sphere once the list-based `init_sphere` port lands (§1.4); this is the argued, expected re-baseline the register row already documents. Capture a fresh set immediately before the port so the diff is attributable to TS-46 alone, not conflated with any other change in the same window. - The step-height regression: any test currently asserting `stepUpHeight/stepDownHeight == 0.4f` for a remote mover will need updating once §1.5's plumbing lands (values will vary per-Setup instead of being a flat constant) — expect and pre-flag these as intentional breaks, not accidental ones. **Tests expected to break intentionally:** any fixture/golden-value test that encodes the CURRENT remote reflect-suppression formula from §2.1 (if one exists — not found in this pass, but the porting agent should grep for tests asserting `applyBounce`'s old airborne-only condition before deleting the code it tests) should break and be rewritten to assert the new `shouldReflect` formula instead. ### P4 (AP-71, AP-10) **Recommended order:** AP-10 (§5) first — it is the lower-risk, better- understood item (formula already verbatim, only a constant + a genuinely inert-bit promotion). AP-71 (§4) second, and should probably be SPLIT: land the `CanBypassMoveRestrictions` two-bit decode (§4.2, an easy addition to `EntityCollisionFlagsExt`, since the PWD bitfield parse infrastructure already exists per §3.2) and the structural `check_entry_restrictions` gate (§4.1, wired to a stub/always-`0` `restriction_obj` so it's a guaranteed no-op against every current test scenario, per the register row's own "inert in all dev content" argument) SEPARATELY from resolving §4.3's open question (where the live `restriction_obj` value comes from) and the new `RestrictionDB`/house-guest state that answering it would require. Landing the gate function itself with an always-empty restriction source is safe, testable (it should be a structural no-op), and unblocks later work without waiting on the open question. **Fixtures/tests to capture BEFORE touching AP-10:** the existing walkable/ terrain-edge test suite (shoreline walking, any existing water-adjacent `TerrainConformanceTests`-family test) — run and record baseline before the `0f → 0.1f` change, specifically watching for the float/fall regression predicted in §5.2 so it can be attributed correctly (water-depth-caused vs. a pre-existing sticky-bit bug newly exposed). **Tests expected to break intentionally:** none identified for AP-71 (the gate is a no-op with an empty restriction source); for AP-10, any test literally asserting `SampleWaterDepth(...) == 0f` for a dry corner of a partially-water cell will need updating to `0.1f`. --- ## 7. Open questions needing live evidence (not guessed) 1. **§1.5** — confirm `CPartArray::GetStepUpHeight`/`GetStepDownHeight` actually return `setup->step_up_height * this->scale` (BN mangled the FP return into dead stores). Ghidra MCP re-decompile of `0x005180d0`/`0x005180f0` once reachable, or a cdb dump of `st0` at return. 2. **§1.5** — retail's step-UP fallback literal (parallel to the confirmed `0.0399999991f` step-DOWN fallback) was not independently located; only the step-down default was read in the transcribed ranges. Grep `CTransition::step_up`'s full body again for a `0.0399999991f`-shaped float near the `(state & 2)` gate, or confirm via Ghidra. 2b. Confirm acdream's `ObjectInfo.StepUpHeight` default of `0.01f` (`PhysicsGlobals.DefaultStepHeight`) against whatever retail's actual step-up fallback turns out to be (currently AS-CITED it does not match the `0.04f` family used everywhere else — worth a dedicated look). 3. **§2.1** — confirm `CPhysicsObj.state` bit `0x20000` is `PhysicsStateFlags.Sledding` by checking `acclient.h`'s `PhysicsState` enum directly (not done in this pass; acdream's own prior naming was trusted as INFERENCE). 4. **§2.4 (#165)** — the actual root cause is unresolved; run the `ACDREAM_PROBE_RESOLVE`/`ACDREAM_CAPTURE_RESOLVE` capture from §2.4 against a live repro before writing any fix. 5. **§4.3 (AP-71)** — the single highest-value open question in this doc: is `CObjCell.restriction_obj` a mis-attributed BN field name collision between a static DAT-baked count and a live object IID, or does `CEnvCell`/`CLandCell` genuinely overwrite the same field at runtime from house-related wire data? Needs Ghidra MCP (struct layout + xrefs) or a live cdb capture at a real ACE house's locked door. 6. **§5.1** — the exact retail site of the literal `0.1f` water constant (inside `validate_walkable` gated on `isWater`, or elsewhere) was not found; ACE's C# port is trusted as the oracle for the FORMULA but not for the ORIGINAL retail literal's exact address. Low priority — does not block the port (§5.1's conclusion). 7. **§5.3** — who reads `WATER_CONTACT_TS` (bit 0x8) after `SetPositionInternal` writes it. Not found; needs an xref search once Ghidra MCP is reachable, or a broader manual read of the pseudo-C for `& 8`/`| 8` masks near `transient_state` accesses outside the write site already found. 8. **§5.4** — whether acdream's terrain-collision path already matches retail's `ENTIRELY_WATER` + `!ethereal` + `!(state&0x40)` early-exit (swim-through-terrain exemption) was not checked; flagged as unverified, not as a confirmed match or divergence.