# 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. ### 2.4b — P3 diagnostic pass (2026-07-30): (a) and (b) ruled out by dat-free/dat-backed fixtures; (c) is the remaining candidate Campaign P Slice P3 item 4 built the dat-free/dat-backed fixtures the plan asked for (no live client) to discriminate the three candidates directly, without waiting on a fresh live capture. Findings: **(b) ruled out by code reading.** `RuntimeRemotePhysicsUpdater.Tick`'s resolve gate is `rm.CellId != 0 && _physics.Engine.LandblockCount > 0`. `RemoteMotion.CellId` reads `RuntimeEntityRecord.FullCellId` live (bound via `RuntimePhysicsState`'s `readCell = () => record.FullCellId`). Every write site for `FullCellId = 0` was traced (`RuntimeEntityObjectLifetime.cs`: `TryApplyPickup` line ~505, `CommitAcceptedParentCellless` line ~591, `CommitWithdrawal` line ~833) — all three are pickup/parent-attach/delete paths, never reachable for a live, freely-moving remote creature or player mid-session. For such a mover, `FullCellId` is set once by the first `UpdatePosition` and never returns to 0 until the entity despawns. The "one-frame grace" this gate names is therefore genuinely first-spawn-only, as the code's own comment already claimed — not a hidden mid-session tunneling window. **(a) tested directly and does NOT reproduce, on two independent geometries.** The unclamped stall-fail "tail delta" snap (`InterpolationManager.ComputeStep`, `_failCount > StallFailCountThreshold`) hands `ResolveWithTransition` a `targetPos` that can be an arbitrarily large single-tick delta (the full remaining distance to the interpolation queue's tail node, no speed cap). The concern was that this large delta might not sweep correctly and could tunnel through solid geometry in one tick. Tested directly, synthetic-sphere and real-BSP-wall geometry both agree: - `Issue165RemoteWallPenetrationDiagnosticTests.SingleLargeTickJumpThroughObstacle_IsStillBlockedAtSurface` (dat-free, synthetic creature sphere): a single resolve call spanning the ENTIRE 2.4 m approach (the same total distance the pre-existing 30-tick `SphereCollisionFamilyTests.GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetration` covers in 0.08 m increments) stops at the identical surface distance (Y≈10.48–10.54) — the sweep is not distance-limited and catches the large jump exactly as it catches the small-step approach. - `DoorCollisionApparatusTests.Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP` (dat-backed, the REAL Holtburg door Setup `0x020019FF` + GfxObj `0x010044B5` BSP slab used by the existing door apparatus tests): the SAME dead-center front approach the 20-tick `Apparatus_DeadCenter_FrontApproach_BlocksOnBSP` test covers, driven in ONE resolve call for the full 2 m instead of 20 × 0.10 m ticks, reports `CollisionNormalValid=true` and stops at Y=11.4 — before the door's front face (Y≈11.99), matching the small-step result. - A third fixture (`SingleLargeTickJumpStartingInsideObstacleOverlap_DoesNotAcceptTunneledCandidate`) confirms that even a candidate whose START point is already inside a solid obstacle's overlap zone does not sail through to a far target — retail's own `validate_transition` restores `curr_pos` on a non-clean step from inside an overlap (the #184 DO-NOT-RETRY precedent), and acdream matches. Both candidates the fixtures could test without a live capture are therefore RULED OUT as the #165 mechanism: the sweep itself handles large single-tick position deltas correctly against both object-collision (ShadowObjectRegistry sphere) and BSP-wall geometry, and the resolve gate cannot skip mid-session for a live mover. **Conclusion: candidate (c) — render/interpolation presentation lag on the App side — is the remaining candidate**, and it is NOT testable with a physics-fixture-only pass: it is a claim about what gets DRAWN on a given frame relative to the collision-corrected `PhysicsBody.Position`, which requires an App-layer render-frame read (comparing the entity's presented transform against its committed physics position across frames) — outside Core/Runtime and outside what a dat-free/dat-backed fixture can observe. Per the campaign plan ("otherwise write the diagnosis... and STOP"), this item stops here: #165 stays OPEN with (a) and (b) struck from the candidate list by the evidence above, and (c) named as the next concrete step for whoever picks this up (an App-layer render-position vs. physics-position diff across frames, or a live `ACDREAM_PROBE_RESOLVE` capture if a fresh repro is available — see the diagnostic recommendation above, still valid for confirming (c) live). --- ## 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.