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

67 KiB
Raw Blame History

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, 8bb8b20454d56229): the bleed mechanism is frames_stationary_fall (fsf), not cached_velocitycached_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 __thiscalls 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):

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)

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

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:

// 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:

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:150PhysicsObjUpdate.CommitSetPositionTransitionHandleAllCollisions (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:

// 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 entirelyRuntimeRemotePhysicsUpdater.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)

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.csEntityCollisionFlagsExt.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)

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):

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):

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):

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)

// 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:

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-3700TransientState 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:

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:

// 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 inContactTransientStateFlags.Contact:

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.