From 8cbe45f0b228799d68c02310f2db59a86b0eeff1 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 23:58:40 +0200 Subject: [PATCH] docs(research): Campaign P P3/P4 decomp anchors - remote residuals + world specials TS-46: init_sphere 0x0050c670 seeds the Setup's own sphere list; step heights come from setup step_up/step_down x scale (0x005180d0/f0) and the local player already ports this (PlayerModeController.ApplyStepHeights) - remote/ordinary 0.4f pins are a plumbing gap. AD-25: handle_all_collisions 0x00514780 is one uniform CPhysicsObj function; the remote reflect block should swap to the existing PhysicsObjUpdate.HandleAllCollisions port. TS-23: parse/storage/exemption machinery exists; only moverFlags call sites read a GUID heuristic. AP-71: check_entry_restrictions 0x0052b6d0 transcribed; restriction_obj write-site field collision flagged OPEN (Ghidra MCP unreachable). AP-10: ValidateWalkable verbatim-correct; only the dry-corner constant collapsed, plus WATER_CONTACT_TS declared but never written. Co-Authored-By: Claude Opus 5 --- ...29-remote-and-world-specials-pseudocode.md | 1104 +++++++++++++++++ 1 file changed, 1104 insertions(+) create mode 100644 docs/research/2026-07-29-remote-and-world-specials-pseudocode.md diff --git a/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md b/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md new file mode 100644 index 00000000..a15e1825 --- /dev/null +++ b/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md @@ -0,0 +1,1104 @@ +# 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.