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