acdream/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md
Erik 8cbe45f0b2 docs(research): Campaign P P3/P4 decomp anchors - remote residuals + world specials
TS-46: init_sphere 0x0050c670 seeds the Setup's own sphere list; step
heights come from setup step_up/step_down x scale (0x005180d0/f0) and the
local player already ports this (PlayerModeController.ApplyStepHeights) -
remote/ordinary 0.4f pins are a plumbing gap. AD-25: handle_all_collisions
0x00514780 is one uniform CPhysicsObj function; the remote reflect block
should swap to the existing PhysicsObjUpdate.HandleAllCollisions port.
TS-23: parse/storage/exemption machinery exists; only moverFlags call
sites read a GUID heuristic. AP-71: check_entry_restrictions 0x0052b6d0
transcribed; restriction_obj write-site field collision flagged OPEN
(Ghidra MCP unreachable). AP-10: ValidateWalkable verbatim-correct; only
the dry-corner constant collapsed, plus WATER_CONTACT_TS declared but
never written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:58:40 +02:00

62 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.


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.