acdream/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md
Erik bc3277a8ec docs(physics): #165 diagnostic pass - rule out (a)/(b), stop at (c)
Campaign P Slice P3 item 4. Per the plan's explicit instruction, this
is diagnose-only: the research's candidate (a)/(b) mechanisms did not
confirm, so no fix lands here.

Built the dat-free/dat-backed fixtures the plan asked for (no live
client) to test the two mechanisms a physics fixture CAN discriminate:

- (b) ruled out by code reading: RuntimeRemotePhysicsUpdater.Tick's
  resolve gate reads RuntimeEntityRecord.FullCellId live. Every
  FullCellId = 0 write site (TryApplyPickup, CommitAcceptedParentCellless,
  CommitWithdrawal in RuntimeEntityObjectLifetime.cs) is a pickup/
  parent-attach/delete path, never reachable for a live, freely moving
  remote mid-session. The "one-frame grace" is genuinely first-spawn-only.

- (a) tested directly and does not reproduce, on two independent
  geometries: InterpolationManager's unclamped stall-fail "tail delta"
  snap (node_fail_counter > 3) can hand ResolveWithTransition an
  arbitrarily large single-tick targetPos. New fixture tests replace a
  proven small-step sweep (many 0.08-0.10 m ticks) with ONE resolve call
  spanning the entire distance, against both a synthetic creature sphere
  and the real Holtburg door BSP slab (Setup 0x020019FF/GfxObj
  0x010044B5, the existing door-apparatus dat fixture) already used by
  DoorCollisionApparatusTests. Both stop at the identical surface
  distance the small-step tests pin, with a valid collision normal --
  the sweep is not distance-limited and does not tunnel on a large
  single-tick delta.

Candidate (c) -- render/interpolation presentation lag on the App side --
is the remaining hypothesis and is out of scope for a physics-fixture
pass (it's a claim about what gets drawn relative to the committed
PhysicsBody.Position, not something a Core fixture observes). #165
stays OPEN with (a)/(b) struck from the candidate list by the evidence
above and (c) named as the next concrete step (an App-layer render-vs-
physics-position diff, or a fresh live ACDREAM_PROBE_RESOLVE capture).

New tests: Issue165RemoteWallPenetrationDiagnosticTests (dat-free,
3 tests) and DoorCollisionApparatusTests.
Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP (dat-backed,
1 test, skips gracefully without the local dat directory).

dotnet build + dotnet test (Core.Tests 4012/2 skip, Runtime.Tests
425/0) green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:01:11 +02:00

1176 lines
67 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = (resolvedold)/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 280904280957):
```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 274093274128)
```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<CSphere> 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=<path>` 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.4810.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.