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>
67 KiB
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 passingsphereHeight: 1.835(not1.2);SpherePath.InitPathitself was untouched so captured-1.2 replay fixtures stay identical. The dat human Setup0x02000001spheres are(0,0,0.475) r=.48and(0,0,1.350) r=.48(top = Height = 1.835). Retail uses the list verbatim viaCPhysicsObj::transition(0x00512dc0) →init_sphere. The window climb itself was not a step-up-budget bug — do not add a height-budget check to the step-down accept path when revisiting this area. - AD-25 rebuild DO-NOT-RETRY (2026-07-07,
8bb8b204→54d56229): the bleed mechanism isframes_stationary_fall(fsf), notcached_velocity—cached_velocity = (resolved−old)/dtis a separate retail reporting value read only byget_velocity, never fed to the integrator; acdream correctly keeps bothVelocityandCachedVelocityfields — do not collapse them.PhysicsObjUpdate.HandleAllCollisionsis the ported function; the velocity response is gated oncandidateMoved(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_transition0x0050aa70:272593 restorescurr_poson 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'sShadowObjectRegistryapproximates 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'srestriction_obj) hits this directly — the same field name is used for two apparently different purposes in two different functions, and I did not resolve it (Ghidra MCP down); flagged as an open question, not guessed. - feedback_verify_subagent_claims_against_source: every code claim below was verified by reading the actual current file at the cited path/line as part of this research pass (2026-07-29), not carried from memory.
1. P3.1 — TS-46: verbatim Setup sphere list into the transition
1.1 Retail: CPhysicsObj::transition seeds the SpherePath from Setup
CPhysicsObj::transition (0x00512dc0, lines 280904–280957):
CTransition* CPhysicsObj::transition(CPhysicsObj* this, Position* arg2 /*start*/,
Position* arg3 /*end*/, int32_t arg4 /*state*/)
{
CTransition* result = CTransition::makeTransition();
if (result == 0) return 0;
init_object(result, this, get_object_info(this, result, arg4));
CPartArray* pa = this->part_array;
uint32_t numSphere = (pa != 0) ? CPartArray::GetNumSphere(pa) : 0;
if (pa == 0 || numSphere == 0)
init_sphere(result, /*count=*/1, &dummy_sphere, /*scale=*/1.0f);
else
{
float scale = this->m_scale;
CSphere* spheres = CPartArray::GetSphere(pa); // Setup's authored list
init_sphere(result, CPartArray::GetNumSphere(pa), spheres, scale);
}
init_path(result, this->cell, arg2, arg3);
// frames_stationary_fall seed from transient_state bits 0x10/0x20/0x40 ...
if (find_valid_position(result) != 0) return result;
return 0;
}
FACT. The sphere source is CPartArray::GetSphere(part_array) — the
Setup's own CSphere array — not a two-scalar (radius, height)
reconstruction. CPartArray::GetNumSphere bounds it (see below, ≤ 2).
1.2 Retail: SPHEREPATH::init_sphere (0x0050c670, lines 274093–274128)
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), radiusradius - head sphere: center
(0,0,height − radius), radiusradius
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:
- 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 optionalIReadOnlyList<(Vector3,float)>? sphereList = nullparameter that, when supplied, bypasses the scalar reconstruction. - 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 viaGetSetupCylinder'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/StepDownHeightfields 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-down0.0399999991fat 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 ownSetupDAT object and setscontroller.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->scalemultiply fromCPartArray::GetStepUpHeightis 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-348and (implicitly, via itsResolveWithTransitioncall)RuntimeOrdinaryPhysicsUpdater.cshardcodestepUpHeight: 0.4f, stepDownHeight: 0.4fas literal constants at the call site, with an explicit comment atRuntimeRemotePhysicsUpdater.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 owntransient_stateCONTACT_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:150
→ PhysicsObjUpdate.CommitSetPositionTransition → HandleAllCollisions
(the CommitSetPositionTransition wrapper additionally handles the
HitGround/LeaveGround callback ordering retail interleaves at
SetPositionInternal lines 283490-283510 — also verbatim-ported).
The remote dead-reckoning sweep (RuntimeRemotePhysicsUpdater.cs:432-462)
does NOT call this function. It has its own hand-inlined reflect block
with a DIFFERENT, narrower gate:
// 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:
- Sledding case. Retail:
shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding)— whensleddingis true, the!sleddingterm isfalse, so the whole AND collapses tofalseregardless of grounded state, soshouldReflect = trueunconditionally. 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. - 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 → postIntegratePosintoResolveWithTransition's sweep — the sweep SHOULD still catch a wall crossing between two arbitrary points (it's a normal sphere sweep, not distance-limited), so this would only tunnel if the sweep itself has some other gap; or - (b) a one-frame skip of the sweep entirely —
RuntimeRemotePhysicsUpdater.cs:320gates the wholeResolveWithTransitioncall onrm.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-tickSphereCollisionFamilyTests.GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetrationcovers in 0.08 m increments) stops at the identical surface distance (Y≈10.48–10.54) — the sweep is not distance-limited and catches the large jump exactly as it catches the small-step approach.DoorCollisionApparatusTests.Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP(dat-backed, the REAL Holtburg door Setup0x020019FF+ GfxObj0x010044B5BSP slab used by the existing door apparatus tests): the SAME dead-center front approach the 20-tickApparatus_DeadCenter_FrontApproach_BlocksOnBSPtest covers, driven in ONE resolve call for the full 2 m instead of 20 × 0.10 m ticks, reportsCollisionNormalValid=trueand 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 ownvalidate_transitionrestorescurr_poson 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:
-
Wire parse.
src/AcDream.Core.Net\Messages\CreateObject.cs:814-819readsobjectDescriptionFlags = ReadU32(...)— thePublicWeenieDesc._bitfieldtrailer field — with a comment explicitly namingBF_PLAYER_KILLER (0x20),BF_FREE_PKSTATUS (0x200000),BF_PKLITE_PKSTATUS (0x2000000)as "read forIsPK()/IsPKLite()/IsImpenetrable()... previously discarded; now surfaced." -
Decode.
src/AcDream.Core/Physics/EntityCollisionFlags.cs—EntityCollisionFlagsExt.FromPwdBitfield(uint bitfield)decodesIsPlayer|IsPK|IsImpenetrable|IsPKLitefrom the raw PWD bitfield (bit values0x8/0x20/0x200000/0x2000000respectively — the PWD wire bitfield's own numbering, DISTINCT fromObjectInfoState's numbering; these are two different bit-spaces that must not be confused). -
Storage — per-GUID, not target-only.
src/AcDream.Core/Items/ClientObjectTable.cs:813andClientObject.cs:230/325storePublicWeenieBitfieldon everyClientObjectrow, 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-320already demonstrates the exact lookup pattern needed:_domain.EntityObjects.Objects.Get(guid)?.PublicWeenieBitfield. -
Consumer today — target-side only.
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:151-155buildsEntityCollisionFlags(viaFromPwdBitfield) for every registered live entity's SHADOW registration — this is the data OTHER movers collide against (thetargetFlagshalf ofCollisionExemption,src/AcDream.Core/Physics/CollisionExemption.cs:95-126). The exemption code already correctly checksmoverState & ObjectInfoState.IsPKagainsttargetFlags & EntityCollisionFlags.IsPK(lines 108-113) — the LOGIC is complete and correct; only themoverStateINPUT 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 outdoorCLandCellsite 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-3700 — TransientState enum: WATER_CONTACT_TS = 0x8
(bit 3), alongside CONTACT_TS = 0x1, ON_WALKABLE_TS = 0x2.
FACT — retail writes this bit in CPhysicsObj::SetPositionInternal
(0x005153e5-0051545f, lines 283459-283483), in the SAME statement block that
writes CONTACT_TS, immediately after it:
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 inContact → TransientStateFlags.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_collisionsENTIRELY_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:
SphereCollisionFamilyTestsandWindowOpening_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_RESOLVEfixtures 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-basedinit_sphereport 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.4ffor 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.5 — confirm
CPartArray::GetStepUpHeight/GetStepDownHeightactually returnsetup->step_up_height * this->scale(BN mangled the FP return into dead stores). Ghidra MCP re-decompile of0x005180d0/0x005180f0once reachable, or a cdb dump ofst0at return. - §1.5 — retail's step-UP fallback literal (parallel to the confirmed
0.0399999991fstep-DOWN fallback) was not independently located; only the step-down default was read in the transcribed ranges. GrepCTransition::step_up's full body again for a0.0399999991f-shaped float near the(state & 2)gate, or confirm via Ghidra. 2b. Confirm acdream'sObjectInfo.StepUpHeightdefault of0.01f(PhysicsGlobals.DefaultStepHeight) against whatever retail's actual step-up fallback turns out to be (currently AS-CITED it does not match the0.04ffamily used everywhere else — worth a dedicated look). - §2.1 — confirm
CPhysicsObj.statebit0x20000isPhysicsStateFlags.Sleddingby checkingacclient.h'sPhysicsStateenum directly (not done in this pass; acdream's own prior naming was trusted as INFERENCE). - §2.4 (#165) — the actual root cause is unresolved; run the
ACDREAM_PROBE_RESOLVE/ACDREAM_CAPTURE_RESOLVEcapture from §2.4 against a live repro before writing any fix. - §4.3 (AP-71) — the single highest-value open question in this doc:
is
CObjCell.restriction_obja mis-attributed BN field name collision between a static DAT-baked count and a live object IID, or doesCEnvCell/CLandCellgenuinely 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. - §5.1 — the exact retail site of the literal
0.1fwater constant (insidevalidate_walkablegated onisWater, 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). - §5.3 — who reads
WATER_CONTACT_TS(bit 0x8) afterSetPositionInternalwrites it. Not found; needs an xref search once Ghidra MCP is reachable, or a broader manual read of the pseudo-C for& 8/| 8masks neartransient_stateaccesses outside the write site already found. - §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.