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>
1176 lines
67 KiB
Markdown
1176 lines
67 KiB
Markdown
# Research: Campaign P Slices P3 (remote-object residuals) and P4 (world specials)
|
||
|
||
**Filed:** 2026-07-29. **Scope:** research only — no source files modified, no
|
||
build/test run. Ghidra MCP was checked at both `127.0.0.1:8081` and
|
||
`127.0.0.1:8080` and was **unreachable** at research time (`curl` exit 7,
|
||
connection refused on both ports) — every citation below comes from
|
||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja
|
||
pseudo-C) cross-checked against `references/ACE/Source/ACE.Server/Physics/`
|
||
where available. Any place BN's decompile is ambiguous and I could not
|
||
resolve it against ACE is flagged explicitly as an **open question** rather
|
||
than guessed.
|
||
|
||
All addresses are the Sept 2013 EoR build (matches `refs/acclient.pdb`).
|
||
Line numbers are into `acclient_2013_pseudo_c.txt` unless noted.
|
||
|
||
---
|
||
|
||
## 0. Binding context copied from the physics/collision digest
|
||
|
||
Per CLAUDE.md, the digest's DO-NOT-RETRY guidance is binding on this
|
||
research and on whichever agent ports it. Copied verbatim (paraphrased where
|
||
noted) from `claude-memory/project_physics_collision_digest.md` (3-day-old
|
||
snapshot — verify against current code before treating as fact, which this
|
||
doc does throughout):
|
||
|
||
- **TS-46 / window-climb DO-NOT-RETRY (2026-07-06, `aa96d7ad`):** the player
|
||
capsule top was fixed by callers passing `sphereHeight: 1.835` (not `1.2`);
|
||
`SpherePath.InitPath` itself was **untouched** so captured-1.2 replay
|
||
fixtures stay identical. The dat human Setup `0x02000001` spheres are
|
||
**`(0,0,0.475) r=.48`** and **`(0,0,1.350) r=.48`** (top = Height = 1.835).
|
||
Retail uses the list verbatim via `CPhysicsObj::transition` (0x00512dc0) →
|
||
`init_sphere`. The window climb itself was **not** a step-up-budget bug —
|
||
do not add a height-budget check to the step-down accept path when
|
||
revisiting this area.
|
||
- **AD-25 rebuild DO-NOT-RETRY (2026-07-07, `8bb8b204`→`54d56229`):** the
|
||
bleed mechanism is `frames_stationary_fall` (fsf), **not**
|
||
`cached_velocity` — `cached_velocity = (resolved−old)/dt` is a separate
|
||
retail reporting value read only by `get_velocity`, never fed to the
|
||
integrator; acdream correctly keeps both `Velocity` and `CachedVelocity`
|
||
fields — do not collapse them. `PhysicsObjUpdate.HandleAllCollisions` is
|
||
the ported function; the velocity response is gated on `candidateMoved`
|
||
(retail pc:283657) — skipping the response when the candidate didn't move
|
||
lets gravity rebuild the velocity instead of re-zeroing it, or the body
|
||
re-wedges. This rebuild is what P3.2 must extend to the remote sweep, not
|
||
re-derive from scratch.
|
||
- **#184 remote de-overlap DO-NOT-RETRY (2026-07-08, Slice 3):** creatures
|
||
are **Sphere-type**, not cylinders, for collision response — do not touch
|
||
registration when working the mover-side (`ObjectInfo`) flags. Retail's
|
||
own crowd "pincer" (player starts inside two overlapping spheres) wedges
|
||
in retail too (`validate_transition` 0x0050aa70:272593 restores
|
||
`curr_pos` on any non-clean-OK step) — this is not a bug to chase.
|
||
- **feedback_retail_per_cell_shadow_list:** retail collision is a per-cell
|
||
`shadow_object_list`, portal-flood-registered. acdream's `ShadowObjectRegistry`
|
||
approximates but does not fully replicate this — relevant background for
|
||
#165 (a wrong membership/registration theory is the #1 historical false
|
||
lead in this codebase; verify collision-set membership before touching
|
||
response math).
|
||
- **feedback_apparatus_for_physics_bugs:** 3 failed speculative fixes on any
|
||
item below = STOP and build capture/replay apparatus (`ACDREAM_PROBE_RESOLVE`,
|
||
`ACDREAM_CAPTURE_RESOLVE`) before a 4th attempt. This applies most acutely
|
||
to #165 (see §2.3).
|
||
- **feedback_bn_decomp_field_names:** BN's heuristic field names, bitfield
|
||
mush, and stack-slot mis-attribution for many-argument `__thiscall`s are a
|
||
known artifact class. Section 3.1 below (AP-71's `restriction_obj`) hits
|
||
this directly — the same field name is used for two apparently different
|
||
purposes in two different functions, and I did not resolve it (Ghidra MCP
|
||
down); flagged as an open question, not guessed.
|
||
- **feedback_verify_subagent_claims_against_source:** every code claim below
|
||
was verified by reading the actual current file at the cited path/line as
|
||
part of this research pass (2026-07-29), not carried from memory.
|
||
|
||
---
|
||
|
||
## 1. P3.1 — TS-46: verbatim Setup sphere list into the transition
|
||
|
||
### 1.1 Retail: `CPhysicsObj::transition` seeds the SpherePath from Setup
|
||
|
||
`CPhysicsObj::transition` (0x00512dc0, lines 280904–280957):
|
||
|
||
```c
|
||
CTransition* CPhysicsObj::transition(CPhysicsObj* this, Position* arg2 /*start*/,
|
||
Position* arg3 /*end*/, int32_t arg4 /*state*/)
|
||
{
|
||
CTransition* result = CTransition::makeTransition();
|
||
if (result == 0) return 0;
|
||
|
||
init_object(result, this, get_object_info(this, result, arg4));
|
||
|
||
CPartArray* pa = this->part_array;
|
||
uint32_t numSphere = (pa != 0) ? CPartArray::GetNumSphere(pa) : 0;
|
||
|
||
if (pa == 0 || numSphere == 0)
|
||
init_sphere(result, /*count=*/1, &dummy_sphere, /*scale=*/1.0f);
|
||
else
|
||
{
|
||
float scale = this->m_scale;
|
||
CSphere* spheres = CPartArray::GetSphere(pa); // Setup's authored list
|
||
init_sphere(result, CPartArray::GetNumSphere(pa), spheres, scale);
|
||
}
|
||
|
||
init_path(result, this->cell, arg2, arg3);
|
||
// frames_stationary_fall seed from transient_state bits 0x10/0x20/0x40 ...
|
||
if (find_valid_position(result) != 0) return result;
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
**FACT.** The sphere source is `CPartArray::GetSphere(part_array)` — the
|
||
Setup's *own* CSphere array — not a two-scalar (radius, height)
|
||
reconstruction. `CPartArray::GetNumSphere` bounds it (see below, `≤ 2`).
|
||
|
||
### 1.2 Retail: `SPHEREPATH::init_sphere` (0x0050c670, lines 274093–274128)
|
||
|
||
```c
|
||
void SPHEREPATH::init_sphere(SPHEREPATH* this, uint32_t count, CSphere* src, float scale)
|
||
{
|
||
this->num_sphere = (count <= 2) ? count : 2; // HARD CAP AT 2
|
||
for (i = 0; i < this->num_sphere; i++)
|
||
{
|
||
this->local_sphere[i].center = src[i].center * scale; // per-sphere scale
|
||
this->local_sphere[i].radius = src[i].radius * scale;
|
||
}
|
||
// local_low_point = local_sphere[0].center, .z -= local_sphere[0].radius
|
||
this->local_low_point = { local_sphere[0].center.x, local_sphere[0].center.y,
|
||
local_sphere[0].center.z - local_sphere[0].radius };
|
||
}
|
||
```
|
||
|
||
**FACT.** Each of the (≤2) spheres carries its **own origin and radius**,
|
||
each independently scaled by `m_scale` (the object's wire `ObjScale`). This
|
||
is a genuine list, not a symmetric two-scalar capsule — a creature whose
|
||
foot sphere and head sphere have different radii (or non-collinear origins)
|
||
is representable in retail and is NOT representable in acdream's current
|
||
`(radius, height)` API.
|
||
|
||
### 1.3 acdream's current API — the two-scalar reconstruction
|
||
|
||
`SpherePath.InitPath` in `src/AcDream.Core/Physics/TransitionTypes.cs` takes
|
||
`(sphereRadius, sphereHeight)` and reconstructs:
|
||
- foot sphere: center `(0,0,radius)`, radius `radius`
|
||
- head sphere: center `(0,0,height − radius)`, radius `radius`
|
||
|
||
This assumes **one radius for both spheres** and derives the head center
|
||
purely from `height`. Every `ResolveWithTransition` caller passes these two
|
||
scalars:
|
||
|
||
| Caller | File:line | Radius/height source |
|
||
|---|---|---|
|
||
| Local player | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1372` (call at `:1382` `moverFlags:`), `:1795` (call at `:1822`) | `ApplyStepHeights` in `src/AcDream.App/Input/PlayerModeController.cs:533-565` reads `Setup.StepUpHeight`/`Setup.StepDownHeight` (see §1.5) but the RADIUS/HEIGHT scalars themselves are set elsewhere — human Setup 0x02000001 hardcode (0.48/1.835), confirmed correct for the human Setup per the TS-46 window-climb fix. |
|
||
| Remote (grounded/airborne DR sweep) | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:343` (moverFlags at `:372`), `:697` (moverFlags at `:707`) | `GetSetupCylinder` — `(setup.Radius, setup.Height) × ObjScale` (a single radius, not the sphere list) — Slice 3 (#184) narrowing. Falls back to human 0.48/1.835 if `deR < 0.05f`. |
|
||
| Ordinary (non-remote-DR, non-projectile) | `src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs:127` (moverFlags at `:137`) | same `radius, height` pattern (params to the method), Setup-sourced by its caller. |
|
||
| Projectile | `src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs:336` | Setup-local **single**-sphere sweep — already list-correct because retail projectile Setups (arrow/bolt/spell) install exactly one sphere; documented in the live-entity-runtime memory ("Setup shapes ... not always centered"). Out of TS-46's scope — already faithful for the 1-sphere case.
|
||
| Camera probe | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:49` (moverFlags at `:68`) | fixed viewer sphere, not Setup-driven — retail's viewer path also uses a synthetic single sphere (`viewer_sphere`, line 92865) — **not** in TS-46's scope. |
|
||
|
||
`GetSetupCylinder` (grep-confirmed): `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` — returns `(setup.Radius, setup.Height)`, i.e. it already reads DAT Setup fields for radius/height, but those are the Setup's **cylinder** fields (`CSetup.Radius`/`CSetup.Height`, used for e.g. selection/picking), not the **sphere list** (`CSetup.Spheres[]`, a parallel/different field). Confirming this distinction requires reading `DatReaderWriter.DBObjs.Setup`'s actual field set (not done in this pass — flagged in §5).
|
||
|
||
### 1.4 API-migration sketch (two scalars → sphere list)
|
||
|
||
**Goal:** `SpherePath.InitPath` should accept `IReadOnlyList<(Vector3 Center, float Radius)>` (≤2 elements, scaled by `ObjScale` by the caller — matching retail applying `m_scale` inside `init_sphere` itself) instead of `(sphereRadius, sphereHeight)`.
|
||
|
||
Two migration shapes, in order of preference:
|
||
|
||
1. **Overload, not replacement.** Add `SpherePath.InitPath(IReadOnlyList<CSphere> spheres, ...)` alongside the existing `(radius, height)` overload; keep the existing overload as a thin wrapper that synthesizes a 2-sphere list `[(0,0,radius),(0,0,height-radius)]` — i.e. the CURRENT behavior becomes an explicit degenerate case of the new API, not a separate code path. This is the safer "integrate surgically" shape: `ResolveWithTransition`'s signature can add an optional `IReadOnlyList<(Vector3,float)>? sphereList = null` parameter that, when supplied, bypasses the scalar reconstruction.
|
||
2. **Replace the scalar overload's callers one at a time**, starting with the local player (Setup already resolved in `PlayerModeController.ApplyStepHeights` — the sphere list is one more DAT read away, `setup.Spheres`), then remote (Setup already resolved via `GetSetupCylinder`'s call site — same Setup object, just also read `.Spheres`), then ordinary.
|
||
|
||
Either shape preserves the **captured replay fixtures**: for the human
|
||
Setup, the 2-sphere reconstruction from `(0.48, 1.835)` is `(0,0,0.48)r=.48`
|
||
+ `(0,0,1.355)r=.48` — a **5 mm** head-center offset from the dat's actual
|
||
`(0,0,1.350)`. Fixtures captured against the CURRENT scalar path will not
|
||
bit-match a sphere-list port; TS-46's own register row already documents
|
||
this 5 mm gap as the "residual." Re-baselining those fixtures is therefore
|
||
an **expected, argued** re-baseline (the register row is the argument), not
|
||
silent fixture drift — but it must be called out explicitly in the porting
|
||
commit per the digest rule.
|
||
|
||
### 1.5 Where retail derives step-up/step-down height — THE KEY FINDING
|
||
|
||
**FACT, fully resolved — acdream already has the exact retail mechanism,
|
||
and the local-player half is already wired.**
|
||
|
||
Retail chain:
|
||
```
|
||
OBJECTINFO::init (0x0050cf30, lines 274432-274463)
|
||
this->step_up_height = CPhysicsObj::GetStepUpHeight(arg2) // 0x0050ea00
|
||
this->step_down_height = CPhysicsObj::GetStepDownHeight(arg2) // 0x0050ea20
|
||
|
||
CPhysicsObj::GetStepUpHeight (0x0050ea00, lines 276292-276302)
|
||
if (this->part_array == 0) return; // (leaves object_info field untouched)
|
||
return CPartArray::GetStepUpHeight(part_array); // TAILCALL
|
||
|
||
CPartArray::GetStepUpHeight (0x005180d0, lines 286168-286179)
|
||
if (this->setup == 0) return setup; // null
|
||
return this->setup->step_up_height * this->scale; // BN mangled the FP
|
||
// return into dead
|
||
// stack-stores; the
|
||
// arithmetic is a
|
||
// scaled-by-object-
|
||
// scale Setup field
|
||
// read — see below.
|
||
```
|
||
|
||
(`CPartArray::GetStepDownHeight`, 0x005180f0, lines 286183-286194, is the
|
||
identical shape for `setup->step_down_height`.)
|
||
|
||
The **BN mush note**: the decompiled body literally prints
|
||
```c
|
||
setup->step_up_height;
|
||
this->scale;
|
||
return setup;
|
||
```
|
||
i.e. it shows the two field reads as dead statements and returns the
|
||
`CSetup*` pointer, because the real x87-register return value (`st0`) isn't
|
||
tracked through the `__fastcall` tail the way BN's SSA expects. This is
|
||
`feedback_bn_decomp_field_names` class 2 (x87-stack misread). The two
|
||
adjacent field reads with no other use, immediately followed by a `return`
|
||
of a pointer whose only remaining live use is as a "did we bail early"
|
||
sentinel, is the standard BN shape for "return `a * b`" — I treat the
|
||
multiplication as **INFERENCE** (very high confidence, matches the
|
||
established pattern used identically at `GetStepDownHeight` and at
|
||
`CPartArray::GetHeight`, line ~286153, which BN renders the SAME way for a
|
||
confirmed-scaled field) but flag it as not 100%-certain without a Ghidra
|
||
cross-check (currently unreachable) or a cdb dump of `st0` at
|
||
`0x005180dd`/`0x005180fd`.
|
||
|
||
Fallback/gate: `CTransition::step_up` (0x0050b610, lines 273099-273133) and
|
||
the step-down poly-hit path (lines 273240-273338) both default
|
||
`step_up_height`/`step_down_height` to the **literal `0.0399999991f` (0.04
|
||
m)**, and only read `object_info.step_up_height`/`step_down_height` (the
|
||
Setup-derived value above) when `object_info.state & 2` is set (bit 0x2 —
|
||
this is `ObjectInfoState.OnWalkable` in acdream's own enum,
|
||
`src/AcDream.Core/Physics/TransitionTypes.cs:28`). There is also a
|
||
`radius × 0.5` clamp in the poly-hit step-down branch (line 273840:
|
||
`step_down_height = local_sphere->radius * 0.5f` when the initial
|
||
`step_down_height` compare fails) — this clamp path was not fully traced in
|
||
this pass (see §5).
|
||
|
||
**acdream already ports this shape, mostly correctly:**
|
||
|
||
- `ObjectInfo.StepUpHeight`/`StepDownHeight` fields exist
|
||
(`src/AcDream.Core/Physics/TransitionTypes.cs:53-54`) with **defaults
|
||
matching retail's literal fallback** (`0.01f` / `0.04f` — note: 0.01 not
|
||
0.04 for step-up; retail's step-up fallback constant was not independently
|
||
found in this pass, only the step-down `0.0399999991f` at line 273109/273252
|
||
— flagged in §5).
|
||
- **The local player already reads the real Setup values.**
|
||
`src/AcDream.App/Input/PlayerModeController.cs:533-565`
|
||
(`ApplyStepHeights`) resolves the player's own `Setup` DAT object and sets
|
||
`controller.StepUpHeight = setup.StepUpHeight > 0 ? setup.StepUpHeight : 0.4f`
|
||
(same for StepDown), i.e. **this is already the retail mechanism**,
|
||
modulo two gaps: (a) the ×`this->scale` multiply from
|
||
`CPartArray::GetStepUpHeight` is not applied (the raw dat field is used
|
||
unscaled — a real but probably tiny divergence since player ObjScale is
|
||
usually 1.0), and (b) the 0.4 fallback (not retail's 0.04) when Setup is
|
||
absent — this 0.4 literal is a pre-existing acdream constant unrelated to
|
||
retail's actual 0.04 fallback; likely a deliberate "coarser is safer"
|
||
choice from before TS-46 was understood, not argued in a register row.
|
||
- **The remote and ordinary paths do NOT read Setup at all** — both
|
||
`RuntimeRemotePhysicsUpdater.cs:347-348` and (implicitly, via its
|
||
`ResolveWithTransition` call) `RuntimeOrdinaryPhysicsUpdater.cs` hardcode
|
||
`stepUpHeight: 0.4f, stepDownHeight: 0.4f` as literal constants at the
|
||
call site, with an explicit comment at `RuntimeRemotePhysicsUpdater.cs:335-336`:
|
||
*"stepUp/stepDown stay 0.4 (retail derives those from the Setup too — an
|
||
adjacent divergence left as-is)"* — this is the acdream team's own
|
||
existing acknowledgment of exactly this gap, already written down before
|
||
this research pass.
|
||
|
||
**Conclusion for P3.1's step-height sub-question:** the port is a **plumbing
|
||
job, not a research job** — the retail formula, the acdream field shape, and
|
||
even a correct reference implementation (the local player's
|
||
`ApplyStepHeights`) already exist in the tree. The work is: (1) apply the
|
||
same Setup-read-with-0.4-fallback pattern to the remote and ordinary
|
||
callers (reusing the Setup object already fetched for `GetSetupCylinder`),
|
||
and (2) decide whether to add the `× ObjScale` multiply that
|
||
`CPartArray::GetStepUpHeight` performs and that the player path currently
|
||
skips (recommend yes, for full parity, with a conformance test pinning a
|
||
non-1.0-scale creature).
|
||
|
||
---
|
||
|
||
## 2. P3.2 — AD-25 + #165: remote collision response
|
||
|
||
### 2.1 Retail: `handle_all_collisions` is ONE function, used uniformly
|
||
|
||
`CPhysicsObj::handle_all_collisions` (0x00514780, lines 282647-282760) is a
|
||
plain `CPhysicsObj` member — there is no player-only or remote-only variant
|
||
in retail. It has exactly two call sites, both inside `CPhysicsObj::SetPositionInternal`
|
||
overloads:
|
||
|
||
- Line 283524 (inside the single-arg `SetPositionInternal(CTransition*)`
|
||
overload, itself called recursively from the full overload at line
|
||
283942): `handle_all_collisions(this, &edi->collision_info, transient_state & 1, transient_state & 2)`
|
||
— i.e. **args 3/4 come from the object's own `transient_state`
|
||
CONTACT_TS/ON_WALKABLE_TS bits BEFORE this call**, not a hardcoded
|
||
before/after pair.
|
||
- Line 283934 (inside `SetPositionInternal(Position*, SetPositionStruct*, CTransition*)`,
|
||
the placement-failure branch): `handle_all_collisions(this, &edi->collision_info, 0, 0)`
|
||
— forced "was not in contact, was not on walkable" when a placement
|
||
attempt fails outright.
|
||
|
||
Both call sites run for **any** `CPhysicsObj` — player, NPC, remote, door,
|
||
prop. There is no retail fork by mover type.
|
||
|
||
Body (already fully transcribed and analyzed in Core, see §2.2), the
|
||
reflect-vs-suppress gate:
|
||
```c
|
||
// var_10_1 (lines 282653-282657) — reflect UNLESS "prev grounded AND now
|
||
// grounded AND not [some 0x20000 state bit]":
|
||
var_10_1 = 1;
|
||
if (arg4 != 0 && (transient_state & 2) != 0) var_10_1 = 0;
|
||
if (arg4 == 0 || (transient_state&2)==0 || (this->state & 0x20000) != 0)
|
||
var_10_1 = 1;
|
||
// net: var_10_1 == 0 iff arg4!=0 && (ts&2)!=0 && (state&0x20000)==0
|
||
```
|
||
Line 282656's literal shows as the string constant
|
||
`"activation type (%s) with '%s' b…"` — a BN string-constant-folding
|
||
artifact colliding with the actual bitmask; the REAL mask is confirmed by
|
||
the later, unambiguous use at line 282701: `if ((this->state & 0x20000) == 0)`
|
||
inside the same reflect-vs-zero branch. **FACT** (cross-confirmed by the
|
||
unambiguous second read of the same bit): the gate is
|
||
`shouldReflect = !(prevOnWalkable && nowOnWalkable && !bit0x20000)`.
|
||
|
||
`0x20000` on `CPhysicsObj.state` (not `ObjectInfo.state` — a different
|
||
bitfield) is very likely `PhysicsStateFlags.Sledding` by elimination (it is
|
||
the only state bit acdream's own port names in this exact role — see
|
||
`PhysicsObjUpdate.HandleAllCollisions`'s `sledding` variable below) but this
|
||
specific bit-value↔name mapping was **not independently re-derived from
|
||
`acclient.h`** in this pass — **INFERENCE**, high confidence (acdream's own
|
||
prior port already made this identification and it is internally
|
||
consistent with #166's description of sledding needing an ALWAYS-reflect
|
||
path), flagged for a cheap confirmation grep against `acclient.h`'s
|
||
`PhysicsState` enum in the porting session.
|
||
|
||
### 2.2 acdream already has a verbatim, correct port
|
||
|
||
`src/AcDream.Core/Physics/PhysicsObjUpdate.cs:152-191`
|
||
(`PhysicsObjUpdate.HandleAllCollisions`) is a **line-for-line correct port**
|
||
of the above:
|
||
|
||
```csharp
|
||
bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding);
|
||
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
|
||
if (body.FramesStationaryFall <= 1)
|
||
{
|
||
if (shouldReflect && collisionNormalValid)
|
||
{
|
||
if (Inelastic) body.Velocity = Vector3.Zero;
|
||
else { /* elastic reflect: v += -(dot(v,n))*(e+1)*n when dot<0 */ }
|
||
}
|
||
}
|
||
else { body.Velocity = Vector3.Zero; } // fsf>1 bleed
|
||
```
|
||
|
||
This is already used for the **local player AND every "ordinary" body**
|
||
(non-remote-DR, non-projectile) via `RuntimeOrdinaryPhysicsUpdater.cs:150`
|
||
→ `PhysicsObjUpdate.CommitSetPositionTransition` → `HandleAllCollisions`
|
||
(the `CommitSetPositionTransition` wrapper additionally handles the
|
||
HitGround/LeaveGround callback ordering retail interleaves at
|
||
`SetPositionInternal` lines 283490-283510 — also verbatim-ported).
|
||
|
||
**The remote dead-reckoning sweep (`RuntimeRemotePhysicsUpdater.cs:432-462`)
|
||
does NOT call this function.** It has its own hand-inlined reflect block
|
||
with a DIFFERENT, narrower gate:
|
||
|
||
```csharp
|
||
// RuntimeRemotePhysicsUpdater.cs:436-439 (current)
|
||
bool applyBounce = sledding
|
||
? !(prevOnWalkable && nowOnWalkable) // WRONG when sledding — see below
|
||
: (!prevOnWalkable && !nowOnWalkable); // WRONG in general — see below
|
||
```
|
||
|
||
**FACT — this is provably narrower than retail in two ways:**
|
||
|
||
1. **Sledding case.** Retail: `shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding)` — when `sledding` is true, the `!sledding` term is `false`, so the whole AND collapses to `false` regardless of grounded state, so `shouldReflect = true` **unconditionally**. Retail's sledding mover ALWAYS runs the reflect/elastic-bounce math — this is the mechanism that produces the "sled" glide-and-bounce feel (#166). The remote code's sledding branch (`!(prevOnWalkable && nowOnWalkable)`) instead SUPPRESSES the bounce whenever grounded-before-and-after, which is the opposite of what sledding is supposed to do.
|
||
2. **Non-sledding case.** Retail: `shouldReflect = !(prevOnWalkable && nowOnWalkable)` — reflects on every transition EXCEPT grounded→grounded (airborne→airborne, airborne→grounded, and grounded→airborne all reflect). The remote code's non-sledding branch (`!prevOnWalkable && !nowOnWalkable`) only reflects on airborne→airborne, explicitly suppressing reflection on the grounded↔airborne transitions retail DOES reflect on.
|
||
|
||
This is exactly what the register row AD-25 describes ("still reflects
|
||
velocity with the airborne-before-AND-after suppression; retail bounces
|
||
unless grounded→grounded-and-not-sledding") — this research pass confirms
|
||
it with the actual formula-level diff, not just the register's prose.
|
||
|
||
### 2.3 Port sketch for AD-25
|
||
|
||
Minimal, surgical fix: delete the hand-inlined block at
|
||
`RuntimeRemotePhysicsUpdater.cs:432-462` and replace the call with
|
||
`PhysicsObjUpdate.HandleAllCollisions(rm.Body, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, prevContact, prevOnWalkable, nowOnWalkable)`,
|
||
computing `prevContact`/`prevOnWalkable` the same way the existing block
|
||
already does (`rm.Body.OnWalkable` before the resolve) and `nowOnWalkable =
|
||
resolveResult.IsOnGround` (already computed). `PhysicsObjUpdate` requires
|
||
`body.FramesStationaryFall` — this field already exists on `PhysicsBody`
|
||
(shared class with the local-player path) so no new state is needed.
|
||
|
||
**Open judgment call for the porting agent, not resolved here:** whether to
|
||
route through the higher-level `PhysicsObjUpdate.CommitSetPositionTransition`
|
||
(which also owns HitGround/LeaveGround dispatch order) instead of the raw
|
||
`HandleAllCollisions`, given the remote path already has its own bespoke
|
||
landing-detection block (lines 464-537, with interp-queue-clear and
|
||
animation-hook-specific logic not present in the ordinary path). Given the
|
||
CLAUDE.md "integrate surgically... change the MINIMUM necessary" rule, the
|
||
narrower `HandleAllCollisions`-only swap is the safer first cut; folding in
|
||
`CommitSetPositionTransition` is a larger, separately-arguable refactor.
|
||
|
||
### 2.4 #165 — remote wall penetration: FACT vs HYPOTHESIS
|
||
|
||
**FACT (retail mechanism, `PositionManager::adjust_offset` 0x00555190 →
|
||
`InterpolationManager::adjust_offset` 0x00555d30, lines 353071-353257):**
|
||
the catch-up step is a `Position::subtract2`-derived delta Frame, magnitude-
|
||
clamped per tick to `catchUpSpeed × dt` where `catchUpSpeed = max_speed(or
|
||
adjusted_max_speed) × MAX_INTERPOLATED_VELOCITY_MOD(2.0)`, with a
|
||
fallback constant `MAX_INTERPOLATED_VELOCITY = 7.5` (line 353137,
|
||
`0x40f00000`) when no minterp/max-speed is available. There is also a
|
||
5-frame stall window (`frame_counter >= 5`) that can raise `node_fail_counter`,
|
||
and a hard **unclamped** snap when `node_fail_counter > 3`
|
||
(`InterpolationManager::UseTime`, 0x00555f20 — "blip to tail/head", lines
|
||
353261+): the delta becomes the FULL remaining distance to the target with
|
||
no speed cap at all.
|
||
|
||
**FACT (acdream's port):** `src/AcDream.Core/Physics/InterpolationManager.cs`
|
||
is a documented, careful, near-verbatim port of the above (constants
|
||
`MaxInterpolatedVelocityMod = 2.0f`, `MaxInterpolatedVelocity = 7.5f`,
|
||
`StallCheckFrameInterval = 5`, `StallFailCountThreshold = 3` all cite the
|
||
exact retail addresses/lines). `ComputeStep` (lines 372-482) clamps the
|
||
per-tick step to `min(catchUp × dt, dist)` (line 470-474) in the normal
|
||
case, and produces the SAME unclamped "tail-delta" snap
|
||
(`_failCount > StallFailCountThreshold`, lines 458-467) that retail does.
|
||
|
||
**INFERENCE / open — not confirmed in this pass:** whether the *observed*
|
||
#165 symptom ("swallowed a bit by the wall") traces to:
|
||
|
||
- (a) the **unclamped stall-fail snap** committing a position on the far
|
||
side of / inside a wall in one tick, which is then fed as
|
||
`preIntegratePos → postIntegratePos` into `ResolveWithTransition`'s sweep
|
||
— the sweep SHOULD still catch a wall crossing between two arbitrary
|
||
points (it's a normal sphere sweep, not distance-limited), so this would
|
||
only tunnel if the sweep itself has some other gap; or
|
||
- (b) a **one-frame skip of the sweep entirely** — `RuntimeRemotePhysicsUpdater.cs:320`
|
||
gates the whole `ResolveWithTransition` call on `rm.CellId != 0 && LandblockCount > 0`,
|
||
with a comment calling this "a one-frame grace until the first UP arrives" —
|
||
if this grace condition is reachable on ANY tick other than
|
||
first-spawn (e.g. transiently during a landblock/cell transition), the raw
|
||
catch-up position commits unswept for that tick, which would look exactly
|
||
like "swallowed a bit" before the NEXT tick's sweep clamps it back; or
|
||
- (c) render/interpolation smoothing on the App side displaying an
|
||
intermediate frame ahead of the collision-corrected position (a
|
||
presentation lag, not a physics tunnel) — not investigated in this pass
|
||
at all (out of Core/Runtime scope, would need an App-layer render-frame
|
||
read).
|
||
|
||
None of (a)/(b)/(c) is confirmed; I did not find a smoking gun equivalent to
|
||
§1.5's or §2.1-2.2's clean formula diff. This matches the ISSUES.md #165
|
||
entry's own framing (three ranked candidates, "capture first").
|
||
|
||
**Diagnostic recommendation (reusing existing apparatus, per the
|
||
apparatus-before-3rd-attempt rule):** `ACDREAM_PROBE_RESOLVE=1` filtered to
|
||
the specific remote's guid at the moment of wall contact will show, per
|
||
resolve call, whether the resolver reports `Collided`-with-position-still-
|
||
inside-geometry (implicates the sweep/BSP itself — candidate outside this
|
||
research's scope, would reopen a #98-class investigation) versus a resolve
|
||
that never fired at all that tick (implicates candidate (b), the grace-skip
|
||
condition) versus a resolve whose INPUT `preIntegratePos` is already inside
|
||
the wall (implicates candidate (a), the unclamped stall snap feeding a bad
|
||
start point). `ACDREAM_CAPTURE_RESOLVE=<path>` would let this be replayed
|
||
offline against the trajectory-replay harness. Both are existing tools;
|
||
no new instrumentation is needed to start.
|
||
|
||
### 2.4b — P3 diagnostic pass (2026-07-30): (a) and (b) ruled out by dat-free/dat-backed fixtures; (c) is the remaining candidate
|
||
|
||
Campaign P Slice P3 item 4 built the dat-free/dat-backed fixtures the
|
||
plan asked for (no live client) to discriminate the three candidates
|
||
directly, without waiting on a fresh live capture. Findings:
|
||
|
||
**(b) ruled out by code reading.** `RuntimeRemotePhysicsUpdater.Tick`'s
|
||
resolve gate is `rm.CellId != 0 && _physics.Engine.LandblockCount > 0`.
|
||
`RemoteMotion.CellId` reads `RuntimeEntityRecord.FullCellId` live (bound via
|
||
`RuntimePhysicsState`'s `readCell = () => record.FullCellId`). Every write
|
||
site for `FullCellId = 0` was traced (`RuntimeEntityObjectLifetime.cs`:
|
||
`TryApplyPickup` line ~505, `CommitAcceptedParentCellless` line ~591,
|
||
`CommitWithdrawal` line ~833) — all three are pickup/parent-attach/delete
|
||
paths, never reachable for a live, freely-moving remote creature or player
|
||
mid-session. For such a mover, `FullCellId` is set once by the first
|
||
`UpdatePosition` and never returns to 0 until the entity despawns. The
|
||
"one-frame grace" this gate names is therefore genuinely first-spawn-only,
|
||
as the code's own comment already claimed — not a hidden mid-session
|
||
tunneling window.
|
||
|
||
**(a) tested directly and does NOT reproduce, on two independent
|
||
geometries.** The unclamped stall-fail "tail delta" snap
|
||
(`InterpolationManager.ComputeStep`, `_failCount > StallFailCountThreshold`)
|
||
hands `ResolveWithTransition` a `targetPos` that can be an arbitrarily large
|
||
single-tick delta (the full remaining distance to the interpolation queue's
|
||
tail node, no speed cap). The concern was that this large delta might not
|
||
sweep correctly and could tunnel through solid geometry in one tick.
|
||
Tested directly, synthetic-sphere and real-BSP-wall geometry both agree:
|
||
|
||
- `Issue165RemoteWallPenetrationDiagnosticTests.SingleLargeTickJumpThroughObstacle_IsStillBlockedAtSurface`
|
||
(dat-free, synthetic creature sphere): a single resolve call spanning the
|
||
ENTIRE 2.4 m approach (the same total distance the pre-existing 30-tick
|
||
`SphereCollisionFamilyTests.GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetration`
|
||
covers in 0.08 m increments) stops at the identical surface distance
|
||
(Y≈10.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 Setup `0x020019FF` + GfxObj
|
||
`0x010044B5` BSP slab used by the existing door apparatus tests): the
|
||
SAME dead-center front approach the 20-tick
|
||
`Apparatus_DeadCenter_FrontApproach_BlocksOnBSP` test covers, driven in
|
||
ONE resolve call for the full 2 m instead of 20 × 0.10 m ticks, reports
|
||
`CollisionNormalValid=true` and stops at Y=11.4 — before the door's front
|
||
face (Y≈11.99), matching the small-step result.
|
||
- A third fixture (`SingleLargeTickJumpStartingInsideObstacleOverlap_DoesNotAcceptTunneledCandidate`)
|
||
confirms that even a candidate whose START point is already inside a
|
||
solid obstacle's overlap zone does not sail through to a far target —
|
||
retail's own `validate_transition` restores `curr_pos` on a non-clean
|
||
step from inside an overlap (the #184 DO-NOT-RETRY precedent), and
|
||
acdream matches.
|
||
|
||
Both candidates the fixtures could test without a live capture are
|
||
therefore RULED OUT as the #165 mechanism: the sweep itself handles large
|
||
single-tick position deltas correctly against both object-collision
|
||
(ShadowObjectRegistry sphere) and BSP-wall geometry, and the resolve gate
|
||
cannot skip mid-session for a live mover.
|
||
|
||
**Conclusion: candidate (c) — render/interpolation presentation lag on the
|
||
App side — is the remaining candidate**, and it is NOT testable with a
|
||
physics-fixture-only pass: it is a claim about what gets DRAWN on a given
|
||
frame relative to the collision-corrected `PhysicsBody.Position`, which
|
||
requires an App-layer render-frame read (comparing the entity's presented
|
||
transform against its committed physics position across frames) — outside
|
||
Core/Runtime and outside what a dat-free/dat-backed fixture can observe.
|
||
Per the campaign plan ("otherwise write the diagnosis... and STOP"), this
|
||
item stops here: #165 stays OPEN with (a) and (b) struck from the
|
||
candidate list by the evidence above, and (c) named as the next concrete
|
||
step for whoever picks this up (an App-layer render-position vs.
|
||
physics-position diff across frames, or a live `ACDREAM_PROBE_RESOLVE`
|
||
capture if a fresh repro is available — see the diagnostic recommendation
|
||
above, still valid for confirming (c) live).
|
||
|
||
---
|
||
|
||
## 3. P3.3 — TS-23: PK/PKLite/Impenetrable mover bits
|
||
|
||
### 3.1 Retail bit values — `OBJECTINFO::init` (0x0050cf30, lines 274432-274463)
|
||
|
||
```c
|
||
void OBJECTINFO::init(OBJECTINFO* this, CPhysicsObj* obj, int32_t state)
|
||
{
|
||
this->object = obj;
|
||
this->state = state;
|
||
this->scale = obj->m_scale;
|
||
this->step_up_height = CPhysicsObj::GetStepUpHeight(obj); // see §1.5
|
||
this->step_down_height = CPhysicsObj::GetStepDownHeight(obj);
|
||
this->ethereal = (obj->state & 4);
|
||
this->step_down = !(obj->state >> 6) & 1;
|
||
CWeenieObject* w = obj->weenie_obj;
|
||
if (w != 0)
|
||
{
|
||
if (w->vtable->IsImpenetrable()) this->state |= 0x80; // IsImpenetrable
|
||
if (w->vtable->IsPlayer()) this->state |= 0x100; // IsPlayer
|
||
if (w->vtable->IsPK()) this->state |= 0x800; // IsPK
|
||
if (w->vtable->IsPKLite()) this->state |= 0x1000; // IsPKLite
|
||
}
|
||
}
|
||
```
|
||
|
||
**FACT.** This exactly matches acdream's `ObjectInfoState` enum
|
||
(`src/AcDream.Core/Physics/TransitionTypes.cs:24-44`): `IsImpenetrable = 0x080`,
|
||
`IsPlayer = 0x100`, `EdgeSlide = 0x200` (retail sets EdgeSlide elsewhere, not
|
||
in this function — acdream's own comment at
|
||
`RuntimeOrdinaryPhysicsUpdater.cs:138` etc. treats it as bundled with
|
||
IsPlayer, a pre-existing simplification not re-litigated here), `IsPK =
|
||
0x800`, `IsPKLite = 0x1000` — the enum already carries a doc comment citing
|
||
`acclient_2013_pseudo_c.txt:276807-276839` (the `FindObjCollisions` PvP
|
||
exemption block) for these exact values, so this is not new discovery — it
|
||
CONFIRMS the enum values already in the tree are correct.
|
||
|
||
### 3.2 acdream already parses this data — it is just not plumbed onto the mover
|
||
|
||
Three layers already exist, fully wired, verified by reading the current
|
||
files:
|
||
|
||
1. **Wire parse.** `src/AcDream.Core.Net\Messages\CreateObject.cs:814-819`
|
||
reads `objectDescriptionFlags = ReadU32(...)` — the `PublicWeenieDesc._bitfield`
|
||
trailer field — with a comment explicitly naming `BF_PLAYER_KILLER (0x20)`,
|
||
`BF_FREE_PKSTATUS (0x200000)`, `BF_PKLITE_PKSTATUS (0x2000000)` as "read
|
||
for `IsPK()`/`IsPKLite()`/`IsImpenetrable()`... previously discarded; now
|
||
surfaced."
|
||
2. **Decode.** `src/AcDream.Core/Physics/EntityCollisionFlags.cs` —
|
||
`EntityCollisionFlagsExt.FromPwdBitfield(uint bitfield)` decodes
|
||
`IsPlayer|IsPK|IsImpenetrable|IsPKLite` from the raw PWD bitfield (bit
|
||
values `0x8`/`0x20`/`0x200000`/`0x2000000` respectively — the PWD wire
|
||
bitfield's own numbering, DISTINCT from `ObjectInfoState`'s numbering;
|
||
these are two different bit-spaces that must not be confused).
|
||
3. **Storage — per-GUID, not target-only.** `src/AcDream.Core/Items/ClientObjectTable.cs:813`
|
||
and `ClientObject.cs:230/325` store `PublicWeenieBitfield` on **every**
|
||
`ClientObject` row, keyed by GUID — this includes the local player's own
|
||
row and every remote's own row, not just "targets" of someone else's
|
||
collision check. `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:318-320`
|
||
already demonstrates the exact lookup pattern needed:
|
||
`_domain.EntityObjects.Objects.Get(guid)?.PublicWeenieBitfield`.
|
||
|
||
4. **Consumer today — target-side only.** `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:151-155`
|
||
builds `EntityCollisionFlags` (via `FromPwdBitfield`) for every
|
||
registered live entity's SHADOW registration — this is the data OTHER
|
||
movers collide against (the `targetFlags` half of
|
||
`CollisionExemption`, `src/AcDream.Core/Physics/CollisionExemption.cs:95-126`).
|
||
The exemption code already correctly checks `moverState & ObjectInfoState.IsPK`
|
||
against `targetFlags & EntityCollisionFlags.IsPK` (lines 108-113) — the
|
||
LOGIC is complete and correct; only the `moverState` INPUT is wrong.
|
||
|
||
**The actual gap, confirmed at all four current call sites** (grep-exhaustive
|
||
in this pass, `select:` on `ObjectInfoState.IsPlayer \|`):
|
||
|
||
| Site | File:line | Current `moverFlags` |
|
||
|---|---|---|
|
||
| Local player, grounded/ordinary | `PlayerMovementController.cs:1382` | `ObjectInfoState.IsPlayer \| ObjectInfoState.EdgeSlide` (hardcoded) |
|
||
| Local player, second call site | `PlayerMovementController.cs:1822` | `ObjectInfoState.IsPlayer` (hardcoded, no EdgeSlide) |
|
||
| Remote DR sweep | `RuntimeRemotePhysicsUpdater.cs:372` | `IsPlayerGuid(serverGuid) ? IsPlayer\|EdgeSlide : EdgeSlide` |
|
||
| Remote DR sweep, 2nd site | `RuntimeRemotePhysicsUpdater.cs:707` | same pattern |
|
||
| Ordinary updater | `RuntimeOrdinaryPhysicsUpdater.cs:138` | `IsPlayerGuid(record.ServerGuid) ? IsPlayer\|EdgeSlide : EdgeSlide` |
|
||
| Remote teleport | `RemoteTeleportController.cs:290` | `IsPlayer\|EdgeSlide : ...` (non-player branch not read in this pass) |
|
||
|
||
`IsPlayerGuid(guid) => (guid & 0xFF000000u) == 0x50000000u` (a cheap GUID-
|
||
prefix heuristic, unrelated to the wire bitfield) is the ONLY player
|
||
detection in play at these sites — none of them consult
|
||
`PublicWeenieBitfield`/`FromPwdBitfield` at all. The
|
||
`RuntimeRemotePhysicsUpdater.cs:358-371` comment block ALREADY documents
|
||
this exact gap in the team's own words before this research pass: *"PK/
|
||
PKLite/Impenetrable are NOT plumbed onto the remote mover yet, so a PK pair
|
||
walks through where retail collides — the SAME M1.5 gap the local player
|
||
carries (see TS-23...)."*
|
||
|
||
### 3.3 Plumb path
|
||
|
||
At each site: resolve the mover's own `ClientObject` via the already-owned
|
||
`ClientObjectTable` accessor (per J3.4, `RuntimeEntityObjectLifetime` owns
|
||
the canonical table; `PlayerMovementController`/`RuntimeRemotePhysicsUpdater`/
|
||
`RuntimeOrdinaryPhysicsUpdater` are all `AcDream.Runtime` classes, which
|
||
CAN reference `AcDream.Core.Items.ClientObjectTable` since Runtime depends
|
||
on Core, not the reverse — no new project reference needed), read
|
||
`.PublicWeenieBitfield`, decode via `EntityCollisionFlagsExt.FromPwdBitfield`
|
||
(already exists), then translate the RESULT's `EntityCollisionFlags` bits
|
||
into `ObjectInfoState` bits (they are numerically different bit-spaces —
|
||
`EntityCollisionFlags.IsPK = 0x04` vs `ObjectInfoState.IsPK = 0x800` — this
|
||
translation is a new 3-line helper, not existing code) and OR them into the
|
||
`moverFlags` argument already being constructed at each site.
|
||
|
||
**Exact wiring at each of the 5-6 call sites (constructor/field access for
|
||
`ClientObjectTable` at each class) was not traced in this pass** — the
|
||
`AcDream.Runtime` classes listed do not currently hold a `ClientObjectTable`
|
||
reference (confirmed by grep: `RuntimeInventoryState.cs`,
|
||
`RuntimeHostileTargetQuery.cs`, `RuntimeEntityObjectLifetime.cs`,
|
||
`RuntimeEntityObjectViews.cs`, `RuntimeEntityObjectEventStream.cs`, and
|
||
`LiveSessionEventRouter.cs` are the only Runtime files referencing it today
|
||
— `PlayerMovementController`/`RuntimeRemotePhysicsUpdater`/
|
||
`RuntimeOrdinaryPhysicsUpdater` are NOT in that list). The porting agent
|
||
will need to either inject the table (constructor param) or add a small
|
||
read-only accessor delegate (matching the existing `IsPlayerGuid`-style
|
||
static-predicate pattern already used at these sites) — a scoped DI/plumbing
|
||
decision, not a research question.
|
||
|
||
### 3.4 Non-PK invariant test (explicitly required by the plan)
|
||
|
||
The plan requires "non-PK ACE behavior must be provably unchanged." Given
|
||
§3.1's decode: a `ClientObject` with `PublicWeenieBitfield == null` (never
|
||
received, e.g. a non-player prop) or with none of bits `0x20/0x200000/0x2000000`
|
||
set decodes to `EntityCollisionFlags.None` for the PK/PKLite/Impenetrable
|
||
bits, which translates to `ObjectInfoState.None` for those bits — i.e. the
|
||
OR is a no-op and `moverFlags` is bit-identical to today's hardcoded value
|
||
for any non-PK, non-PKLite, non-Impenetrable mover (which is every mover in
|
||
ACE's default character-creation state, per the existing TS-23 register
|
||
row's own text). The invariant test: construct movers with
|
||
`PublicWeenieBitfield = null` and `PublicWeenieBitfield = 0` and assert
|
||
`ResolveWithTransition`'s effective `moverFlags` is unchanged from the
|
||
pre-port value in both cases; a second test sets the PK bit on a
|
||
`ClientObjectTable` row and asserts the walk-through exemption in
|
||
`CollisionExemption` (already-correct logic, §3.2 point 4) now actually
|
||
fires for a PK-vs-PK pair and does NOT fire for a PK-vs-non-PK pair — this
|
||
second test is the actual acceptance criterion for TS-23, not just the
|
||
non-regression guard.
|
||
|
||
---
|
||
|
||
## 4. P4.1 — AP-71: `check_entry_restrictions`
|
||
|
||
### 4.1 Retail: `CObjCell::check_entry_restrictions` (0x0052b6d0, lines 308873-308912)
|
||
|
||
```c
|
||
TransitionState CObjCell::check_entry_restrictions(CObjCell* this, CTransition* t)
|
||
{
|
||
CPhysicsObj* mover = t->object_info.object;
|
||
if (mover != 0)
|
||
{
|
||
CWeenieObject* moverWeenie = mover->weenie_obj;
|
||
if (moverWeenie == 0) return OK_TS; // no weenie → pass
|
||
|
||
int32_t canBypass = moverWeenie->vtable->CanBypassMoveRestrictions();
|
||
|
||
if ((t->object_info.state & 0x100) == 0) // NOT IsPlayer
|
||
return OK_TS; // NPCs bypass entirely
|
||
|
||
uint32_t restrictionObj = this->restriction_obj;
|
||
if (restrictionObj == 0 || canBypass)
|
||
return OK_TS;
|
||
|
||
CPhysicsObj* rObj = CPhysicsObj::GetObjectA(restrictionObj);
|
||
if (rObj != 0)
|
||
{
|
||
CWeenieObject* rWeenie = rObj->weenie_obj;
|
||
if (rWeenie != 0)
|
||
{
|
||
if (rWeenie->vtable->CanMoveInto(moverWeenie) != 0)
|
||
return OK_TS;
|
||
this->vtable->handle_move_restriction(t); // side effect (msg/anim)
|
||
}
|
||
}
|
||
}
|
||
return COLLIDED_TS; // (2)
|
||
}
|
||
```
|
||
|
||
**FACT, confirmed doubly:** this gate is called from BOTH the outdoor and
|
||
indoor collision entry points, at the exact same position (first thing in
|
||
the function, before any BSP work):
|
||
|
||
- `CEnvCell::find_env_collisions` (0x0052c130, line 309576):
|
||
`check_entry_restrictions(this, arg2)` is the first statement.
|
||
- `CLandCell::find_env_collisions` (0x00532f20, line 317075):
|
||
`if (check_entry_restrictions(this, ebp) != OK_TS) return;` — same
|
||
pattern, one function earlier in the call than I initially expected
|
||
(AP-71's register row cites only the EnvCell/indoor site; **this research
|
||
additionally confirms the outdoor `CLandCell` site uses the identical
|
||
gate** — a scope expansion the porting agent should know about: a
|
||
restricted OUTDOOR cell, if that's ever content-modeled, is gated the
|
||
same way).
|
||
|
||
**FACT — only PLAYERS are gated.** The `(state & 0x100) == 0 → OK_TS` early
|
||
return means NPCs/monsters/props bypass this check entirely regardless of
|
||
`restriction_obj`. `ObjectInfoState.IsPlayer = 0x100` matches acdream's
|
||
existing enum exactly (§3.1).
|
||
|
||
### 4.2 `CanBypassMoveRestrictions` / `CanMoveInto` — the house-guest mechanism
|
||
|
||
`ACCWeenieObject::CanBypassMoveRestrictions` (0x0058c500, lines 406605-406614):
|
||
```c
|
||
bool CanBypassMoveRestrictions(ACCWeenieObject* this)
|
||
{
|
||
uint32_t bf = this->pwd._bitfield;
|
||
return (bf & 0x100000) != 0 && (bf & 0x400000) != 0;
|
||
}
|
||
```
|
||
**FACT.** Two PWD bitfield bits ANDed together (both must be set) — the
|
||
register row cites these same two bit values (0x100000/0x400000); this
|
||
research confirms the exact AND-combination logic, which was not previously
|
||
spelled out.
|
||
|
||
`ACCWeenieObject::CanMoveInto` (0x0058da40, lines 407982-408056):
|
||
```c
|
||
bool CanMoveInto(ACCWeenieObject* this, CWeenieObject* mover)
|
||
{
|
||
if (mover == 0) return false; // conservative
|
||
if (this->pwd._house_owner_iid == 0
|
||
|| this->pwd._house_owner_iid == mover->id)
|
||
return true; // owner always in
|
||
RestrictionDB* db = this->pwd._db;
|
||
if (db == 0) return true; // no list → open
|
||
if (RestrictionDB::IsAllowedIn(db, mover->id, mover->allegiance_or_similar_at_0x128))
|
||
return true;
|
||
// ... not allowed: PlayScript(pscript, ...) visual/audio denial cue
|
||
return false;
|
||
}
|
||
```
|
||
**FACT.** This IS the AC house-guest/lock mechanism —
|
||
`pwd._house_owner_iid` (the house owner's instance ID) and `pwd._db`
|
||
(a `RestrictionDB*` — presumably the guest/ban list) live on the
|
||
RESTRICTION OBJECT's own weenie (the object pointed at by
|
||
`this->restriction_obj`, resolved via `CPhysicsObj::GetObjectA`), not on the
|
||
cell or the mover.
|
||
|
||
### 4.3 OPEN QUESTION — where `restriction_obj`'s value comes from (not resolved; Ghidra MCP down)
|
||
|
||
`CObjCell::check_entry_restrictions` reads `this->restriction_obj` as a
|
||
**uint32 object IID**, resolved via `CPhysicsObj::GetObjectA(restriction_obj)`
|
||
to find a live weenie. Tracing where `restriction_obj` is ASSIGNED, I found
|
||
exactly one write site: `CEnvCell::UnPack` (0x0052d470, line 310871):
|
||
|
||
```c
|
||
this->restriction_obj = (uint32_t)ecx_5; // a single BYTE read from the
|
||
// cell's DAT unpack stream
|
||
```
|
||
|
||
immediately followed (line 310912) by using **the same field** as a COUNT
|
||
to allocate an array: `operator new[]((restriction_obj * 0x18) + 4)` — i.e.
|
||
in `UnPack`, this looks like a small integer count (0-255, since it's an
|
||
8-bit read) driving allocation of `restriction_obj` structures of 0x18 (24)
|
||
bytes each, not a 32-bit object GUID.
|
||
|
||
**These two uses are inconsistent** — a per-cell field that is (a) read as
|
||
a small DAT-baked count used to size an array during static cell UnPack,
|
||
and (b) read as a live 32-bit object IID during collision-gate evaluation,
|
||
cannot both be the literal same struct member unless CEnvCell overwrites it
|
||
at RUNTIME between DAT load and gameplay (e.g. a live server association
|
||
replacing the static field after house/lock data arrives over the wire).
|
||
This is exactly the class of ambiguity `feedback_bn_decomp_field_names`
|
||
warns about — BN's heuristic field-name propagation can attach the SAME
|
||
auto-generated name to two DIFFERENT actual struct offsets it failed to
|
||
disambiguate across two different functions.
|
||
|
||
**Wire-side FACT, for context (not a resolution):** acdream's own
|
||
`GameEventType.cs` already enumerates `HouseData = 0x0225`,
|
||
`HouseStatus = 0x0226`, `HouseUpdateRestrictions = 0x0248` (present in the
|
||
codebase before this research pass — i.e. someone already knew these
|
||
opcodes exist), and `references/Chorizite.ACProtocol/.../protocol.xml`
|
||
additionally lists an `ObjectDescriptionFlag` bit `HouseRestrictions =
|
||
0x4000000` (line 467) and a large family of house-guest opcodes
|
||
(`House_AddPermanentGuest 0x0245`, `House_RemovePermanentGuest 0x0246`,
|
||
`House_RequestFullGuestList 0x024D`, etc.). None of this is currently
|
||
PARSED into any acdream data structure (grep-confirmed: zero non-enum
|
||
matches for `HouseData`/`ObjectDescriptionFlag.HouseRestrictions` in `src/`).
|
||
`references/ACE/Source/ACE.Server/WorldObjects/House.cs` confirms ACE does
|
||
model a full server-side house/guest/monarch system, so this is NOT
|
||
provably inert against every possible local-ACE test scenario (a
|
||
player-built house IS creatable), even though the register row's claim that
|
||
it's inert against the ACE **starter area** specifically stands.
|
||
|
||
**What I could NOT determine in this pass, and recommend as the next
|
||
concrete step:** whether `CObjCell.restriction_obj`'s *live* value (the one
|
||
`check_entry_restrictions` reads) is populated (a) from a completely
|
||
different, mis-attributed static DAT field than the one at
|
||
`UnPack:310871`, or (b) dynamically overwritten at runtime when a
|
||
house-linked cell/portal weenie spawns nearby, and if (b), which of the
|
||
House_* wire messages triggers that write. This needs either (i) Ghidra's
|
||
independent decompiler (down at research time — retry when back up, look
|
||
specifically at `CObjCell`'s struct layout / xrefs to the `restriction_obj`
|
||
offset), or (ii) an ACE-side or holtburger-side house-cell association read
|
||
(not checked in this pass — `references/ACE` does model houses but I did
|
||
not search for where ACE associates a *cell* with a house's restriction
|
||
data specifically), or (iii) a live cdb capture of `this->restriction_obj`
|
||
at `check_entry_restrictions`'s entry (0x0052b6d5) while standing at a
|
||
real ACE-created house's locked door, per the retail debugger toolchain in
|
||
CLAUDE.md.
|
||
|
||
### 4.4 Port pseudocode (structure only — value-source is the open question above)
|
||
|
||
```csharp
|
||
// CObjCell.check_entry_restrictions port sketch
|
||
TransitionState CheckEntryRestrictions(CTransition t)
|
||
{
|
||
var mover = t.ObjectInfo.Object; // the live ClientObject/weenie, if any
|
||
if (mover is null) return TransitionState.OK;
|
||
if (!t.ObjectInfo.IsPlayer) return TransitionState.OK; // NPCs bypass
|
||
if (mover.CanBypassMoveRestrictions()) return TransitionState.OK; // §4.2 two-bit AND
|
||
|
||
uint restrictionObjGuid = this.RestrictionObj; // §4.3 — SOURCE UNRESOLVED
|
||
if (restrictionObjGuid == 0) return TransitionState.OK;
|
||
|
||
var restrictionWeenie = ResolveWeenie(restrictionObjGuid);
|
||
if (restrictionWeenie is { } rw)
|
||
{
|
||
if (rw.CanMoveInto(mover)) return TransitionState.OK; // §4.2 owner/db check
|
||
HandleMoveRestriction(t); // client-side denial cue (message/sound)
|
||
}
|
||
return TransitionState.Collided;
|
||
}
|
||
```
|
||
|
||
**Minimal restriction state acdream must track, once §4.3 resolves:** a
|
||
per-cell nullable `restriction_obj` GUID field (on whatever acdream's
|
||
`CObjCell`-equivalent is — `CellPhysics`, `src/AcDream.Core/Physics/PhysicsDataCache.cs:540`,
|
||
confirmed by the AP-71 register row to currently have NO such field), plus
|
||
enough of the target weenie's data to answer `CanBypassMoveRestrictions`
|
||
(two PWD bitfield bits — already parsed per §3.2, just need the two extra
|
||
bit values `0x100000`/`0x400000` added to `EntityCollisionFlagsExt`) and
|
||
`CanMoveInto` (house owner IID + an allow/ban list — NOT currently modeled
|
||
anywhere in acdream; this is new state, likely fed by
|
||
`HouseData`/`HouseUpdateRestrictions` if §4.3 resolves to the wire-dynamic
|
||
answer).
|
||
|
||
---
|
||
|
||
## 5. P4.2 — AP-10 + water semantics
|
||
|
||
### 5.1 The formula is ALREADY verbatim-correct in acdream; only the source constant is collapsed
|
||
|
||
**FACT, cross-confirmed via ACE's clean C# port (BN mush avoided
|
||
entirely for this item since ACE's port is unambiguous):**
|
||
`references/ACE/Source/ACE.Server/Physics/ObjectInfo.cs:101-171`
|
||
(`ObjectInfo.ValidateWalkable`) — the non-viewer branch, line 124:
|
||
|
||
```csharp
|
||
var dist = Vector3.Dot(checkPos.Center - new Vector3(0, 0, checkPos.Radius),
|
||
contactPlane.Normal) + contactPlane.D + waterDepth;
|
||
if (dist >= -PhysicsGlobals.EPSILON) { /* touching/above → OK, maybe SetContactPlane */ }
|
||
else { /* below → push up by zDist = dist / normal.Z, Adjusted */ }
|
||
```
|
||
|
||
`waterDepth` is added DIRECTLY into the signed plane-distance before the
|
||
`±EPSILON` compare — a positive `waterDepth` lets the sphere's true
|
||
geometric bottom sit up to `waterDepth` meters below the plane while `dist`
|
||
still reads "touching," so `SetContactPlane`/`OnWalkable` still commit.
|
||
|
||
**acdream's own `ValidateWalkable`** (`src/AcDream.Core/Physics/TransitionTypes.cs:3068-3146`)
|
||
is a **byte-for-byte structural match**, already citing "ACE
|
||
`ObjectInfo.ValidateWalkable()` line 124" in its own doc comment (line 3082) —
|
||
i.e. this formula was already correctly ported before this research pass. Line
|
||
3089: `float dist = Vector3.Dot(lowPoint, contactPlane.Normal) + contactPlane.D + waterDepth;`.
|
||
|
||
**The ONLY collapsed piece is the VALUE fed into `waterDepth`.**
|
||
`TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs:538-559`)
|
||
returns `0.9f` for entirely-water cells (matches retail's visual full-
|
||
submersion, unchanged), `0.45f` for a partially-water cell's water corner
|
||
(unchanged), but returns **`0f`** for a partially-water cell's DRY corner
|
||
where retail's own comment (already in the file, `TerrainSurface.cs:522-528`)
|
||
says retail returns **`0.1f`**.
|
||
|
||
I traced `CObjCell::get_water_depth` (0x0052b8a0, lines 309007-309032) and
|
||
its sole caller `CLandCell::find_env_collisions` (0x00532fd5, confirmed by
|
||
line 317094: `float depth = get_water_depth(this, &var_28)`, fed into
|
||
`validate_walkable`'s explicit float argument at line 317111) — this
|
||
confirms the STRUCTURAL fact that water depth flows `get_water_depth →
|
||
validate_walkable` as an argument (matching acdream's own architecture),
|
||
but `get_water_depth` itself (lines 309007-309032) delegates to
|
||
`CLandBlockStruct::calc_water_depth` (0x005315f0, lines 315578-315644) which
|
||
returns a **`TERRAIN_SURF_CHAR[...]` table lookup** (a small integer, 0-31
|
||
range, surface-TYPE code) — **not** a floating-point 0.1 literal. I could
|
||
NOT find the exact site inside `validate_walkable`'s own body (0x0050d010,
|
||
lines 274479-274617) where a `0.1f` water-specific constant is applied to
|
||
`arg5` (the water-depth parameter) — the ONE `-0.1f`-shaped literal I found
|
||
in that function (line 274593, `-0.10000000000000001`) is, per ACE's
|
||
unambiguous cross-reference (`ObjectInfo.cs:154`, `interp < -0.1f`),
|
||
**the step-down `WalkInterp` bound, unrelated to water** — I flag this
|
||
explicitly because I initially suspected it was the water constant and the
|
||
ACE cross-check disproved it; recording this here so a future pass doesn't
|
||
repeat the same false match.
|
||
|
||
**Conclusion:** the `0.1f` retail constant's exact origin (is it a second
|
||
hardcoded literal inside `validate_walkable` gated on `isWater`, or is
|
||
`calc_water_depth`'s `TERRAIN_SURF_CHAR` table itself scaled by some factor
|
||
that produces 0.1 for a specific surface-type code, or is it purely an ACE-
|
||
side reverse-engineered constant with no direct 1:1 retail literal at all)
|
||
is an **open question** — but it does not block the fix: acdream's own
|
||
architecture already matches ACE's `ObjectInfo.ValidateWalkable` formula
|
||
exactly, ACE's C# source is unambiguous that the dry-corner value is a
|
||
constant fed as `waterDepth`, and restoring `TerrainSurface.SampleWaterDepth`'s
|
||
dry-corner return from `0f` to `0.1f` is a 1-line, format-matching change
|
||
regardless of which retail literal ultimately produced 0.1.
|
||
|
||
### 5.2 The "restore risk" is likely a DIFFERENT, adjacent bug — do not re-collapse as a second workaround
|
||
|
||
The existing acdream comment justifying the 0→0.1 collapse
|
||
(`TerrainSurface.cs:522-528`) says a nonzero dry-corner value "destabilizes
|
||
the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane
|
||
never fires...)."
|
||
|
||
**This is structurally true of retail too, by design, not a bug the
|
||
collapse is protecting against.** Looking at `ValidateWalkable`'s "above or
|
||
touching" branch (both retail/ACE and acdream, §5.1): when `dist > EPSILON`
|
||
(strictly above the touching band — which a `+0.1` waterDepth shift WILL
|
||
produce for a character standing statically on dry ground with a
|
||
near-exact geometric contact), the function returns OK **without** calling
|
||
`SetContactPlane`/committing walkable state THIS call — in ALL THREE
|
||
implementations (retail, ACE, acdream), water or not. Retail relies on
|
||
`Contact`/`OnWalkable` being STICKY bits that persist across frames where a
|
||
given call skips the touch reassertion, not on every single call
|
||
re-asserting them. If restoring 0.1 reproduces the described "floats/falls
|
||
every frame" symptom, the likely root cause is that some OTHER acdream code
|
||
path clears `Contact`/`OnWalkable` unconditionally each tick (unlike
|
||
retail's sticky-bit model), and `ValidateWalkable`'s water-shifted skip then
|
||
never gets a chance to reassert — i.e. **the real bug, if it still exists,
|
||
is in whatever clears the sticky bits, not in the water-depth term itself.**
|
||
|
||
Per CLAUDE.md's no-workarounds rule, this makes AP-10 potentially a
|
||
two-step port: (1) restore the `0.1f` dry-corner constant, (2) run the
|
||
existing walkable/terrain-edge regression suite plus a manual
|
||
walk-on-shoreline gate; if the float/fall symptom reproduces, the follow-up
|
||
is root-causing the sticky-bit clearing, not re-collapsing to 0 a second
|
||
time. This is a testable prediction for the porting agent, not a claim that
|
||
the fix is risk-free.
|
||
|
||
### 5.3 `WATER_CONTACT_TS` (bit 0x8) — a genuinely unported piece, found in this pass
|
||
|
||
`acclient.h:3688-3700` — `TransientState` enum: `WATER_CONTACT_TS = 0x8`
|
||
(bit 3), alongside `CONTACT_TS = 0x1`, `ON_WALKABLE_TS = 0x2`.
|
||
|
||
**FACT — retail writes this bit in `CPhysicsObj::SetPositionInternal`**
|
||
(0x005153e5-0051545f, lines 283459-283483), in the SAME statement block that
|
||
writes `CONTACT_TS`, immediately after it:
|
||
|
||
```c
|
||
int32_t contactPlaneIsWater = arg2->collision_info.contact_plane_is_water;
|
||
// ... this->transient_state = (contact_plane_valid) ? (ts|1) : (ts & ~1); // CONTACT_TS
|
||
CPhysicsObj::calc_acceleration(this);
|
||
// then, unconditionally right after:
|
||
this->transient_state = contactPlaneIsWater
|
||
? (this->transient_state | 8) // WATER_CONTACT_TS
|
||
: (this->transient_state & ~8);
|
||
```
|
||
|
||
This is a **persistent, object-level transient-state bit**, mirroring the
|
||
per-resolve local `CollisionInfo.ContactPlaneIsWater` bool onto the body's
|
||
own sticky state — exactly parallel to how `CONTACT_TS`/`ON_WALKABLE_TS` are
|
||
promoted from the resolve's local result onto `transient_state`.
|
||
|
||
**FACT — acdream has NOT ported this promotion.** `src/AcDream.Core/Physics/PhysicsBody.cs:99-103`:
|
||
```csharp
|
||
// Declared to complete retail's TransientState (acclient.h:3688). Neither bit is
|
||
// produced or consumed by acdream's transition yet; they are here so the two free
|
||
// slots cannot be reused for something else and quietly collide with the wire.
|
||
WaterContact = 0x00000008, // bit 3 — WATER_CONTACT_TS
|
||
CheckEthereal = 0x00000100, // bit 8 — CHECK_ETHEREAL_TS
|
||
```
|
||
The bit is DECLARED (reserved, so nothing else accidentally claims it) but
|
||
never WRITTEN — `ContactPlaneIsWater` is extensively threaded through
|
||
`CollisionInfo` and `PhysicsBody.ContactPlaneIsWater` (dozens of call sites,
|
||
grep-confirmed) as a plain bool, but it is never mirrored into
|
||
`PhysicsBody.TransientState`'s `WaterContact` flag the way `Contact`/
|
||
`OnWalkable` already are (via `PhysicsObjUpdate.ApplySetPositionContact`/
|
||
`CommitSetPositionTransition`, `src/AcDream.Core/Physics/PhysicsObjUpdate.cs:30-127`
|
||
— neither of these currently touches `WaterContact`).
|
||
|
||
**Port sketch:** add one line to `PhysicsObjUpdate.ApplySetPositionContact`
|
||
and `CommitSetPositionTransition`, immediately alongside the existing
|
||
`Contact` bit write, mirroring `inContact` → `TransientStateFlags.Contact`:
|
||
```csharp
|
||
if (body.ContactPlaneIsWater)
|
||
body.TransientState |= TransientStateFlags.WaterContact;
|
||
else
|
||
body.TransientState &= ~TransientStateFlags.WaterContact;
|
||
```
|
||
This is a small, low-risk, purely-additive change (the bit is currently
|
||
inert, so writing it cannot regress anything that reads it, because nothing
|
||
reads it).
|
||
|
||
**OPEN — who CONSUMES `WATER_CONTACT_TS` after it's set, in retail?** Not
|
||
found in this pass. I did not do an exhaustive scan for reads of bit 0x8 on
|
||
`transient_state` elsewhere in the 1.4M-line pseudo-C dump (the write site
|
||
was found via the `contact_plane_is_water` local-variable trail, which does
|
||
not generalize to finding reads of the resulting bit, since bit-mask reads
|
||
are not text-greppable without high false-positive noise across unrelated
|
||
0x8 masks). **This is the one genuinely unresolved scope question for
|
||
AP-10:** is `WATER_CONTACT_TS` purely a reporting/query bit (e.g. read by
|
||
some "is player swimming" query for animation/sound/UI, with no gameplay-
|
||
feel consequence), or does something in the movement/friction/step chain
|
||
branch on it? Recommend a follow-up grep pass (or Ghidra MCP xref query
|
||
`/function_xrefs?name=CPhysicsObj::SetPositionInternal` once reachable) as
|
||
the next concrete step before claiming "water semantics" fully verified.
|
||
|
||
### 5.4 Other water-behavior divergences found (facts only, no scope invention)
|
||
|
||
Beyond the AP-10 constant and the WATER_CONTACT_TS gap above, this pass
|
||
found no additional confirmed water-specific divergences. Specifically
|
||
checked and found NO evidence of divergence in:
|
||
- The `CLandCell::find_env_collisions` ENTIRELY_WATER early-exit (line
|
||
317091: `if (block_water_type == ENTIRELY_WATER && !ethereal && !(state&0x40))
|
||
return;` — a swimming/ethereal exemption from terrain collision entirely)
|
||
— acdream's handling of this specific branch was **not cross-checked**
|
||
against this exact condition in this pass; flagged as unverified rather
|
||
than asserted-divergent or asserted-matching.
|
||
- Jump-in-water and movement-effects-in-water (e.g. reduced jump height,
|
||
swim animation triggers) — **not investigated**, out of the physics/
|
||
collision scope this pass focused on; would require a `MovementSystem`/
|
||
animation-side read not attempted here.
|
||
|
||
---
|
||
|
||
## 6. Port order + blast radius
|
||
|
||
### P3 (TS-46, AD-25, #165, TS-23)
|
||
|
||
**Recommended order:** TS-23 (§3) first — it is pure plumbing against
|
||
already-correct logic and already-correct data, lowest risk, and its own
|
||
non-regression test (§3.4) is cheap to write. Then AD-25 (§2.1-2.3) — a
|
||
one-function-call swap with an existing verbatim replacement, second-lowest
|
||
risk. TS-46 (§1) third — touches the collision CAPSULE of every mover, the
|
||
highest-blast-radius item in this batch; do it after the other two so any
|
||
regression is attributable. #165 (§2.4) last, and only after AD-25 lands
|
||
(the remote reflect fix changes remote velocity behavior at walls, which
|
||
could itself mask or unmask the wall-penetration symptom — diagnose #165
|
||
fresh, post-AD-25, not against pre-AD-25 captures).
|
||
|
||
**Fixtures/tests to capture BEFORE touching TS-46:**
|
||
- `SphereCollisionFamilyTests` and `WindowOpening_HeadCannotFit_EntryBlocked`
|
||
(cited in the digest as TS-46-adjacent pins) — re-run and record baseline
|
||
pass/fail before the sphere-list migration.
|
||
- Any `ACDREAM_CAPTURE_RESOLVE` fixtures currently pinned to the human
|
||
Setup's 2-scalar reconstruction (0.48/1.835) — these WILL shift by ~5 mm
|
||
at the head sphere once the list-based `init_sphere` port lands (§1.4);
|
||
this is the argued, expected re-baseline the register row already
|
||
documents. Capture a fresh set immediately before the port so the diff is
|
||
attributable to TS-46 alone, not conflated with any other change in the
|
||
same window.
|
||
- The step-height regression: any test currently asserting
|
||
`stepUpHeight/stepDownHeight == 0.4f` for a remote mover will need
|
||
updating once §1.5's plumbing lands (values will vary per-Setup instead of
|
||
being a flat constant) — expect and pre-flag these as intentional breaks,
|
||
not accidental ones.
|
||
|
||
**Tests expected to break intentionally:** any fixture/golden-value test
|
||
that encodes the CURRENT remote reflect-suppression formula from §2.1 (if
|
||
one exists — not found in this pass, but the porting agent should grep for
|
||
tests asserting `applyBounce`'s old airborne-only condition before deleting
|
||
the code it tests) should break and be rewritten to assert the new
|
||
`shouldReflect` formula instead.
|
||
|
||
### P4 (AP-71, AP-10)
|
||
|
||
**Recommended order:** AP-10 (§5) first — it is the lower-risk, better-
|
||
understood item (formula already verbatim, only a constant + a genuinely
|
||
inert-bit promotion). AP-71 (§4) second, and should probably be SPLIT: land
|
||
the `CanBypassMoveRestrictions` two-bit decode (§4.2, an easy addition to
|
||
`EntityCollisionFlagsExt`, since the PWD bitfield parse infrastructure
|
||
already exists per §3.2) and the structural `check_entry_restrictions` gate
|
||
(§4.1, wired to a stub/always-`0` `restriction_obj` so it's a guaranteed
|
||
no-op against every current test scenario, per the register row's own
|
||
"inert in all dev content" argument) SEPARATELY from resolving §4.3's open
|
||
question (where the live `restriction_obj` value comes from) and the new
|
||
`RestrictionDB`/house-guest state that answering it would require. Landing
|
||
the gate function itself with an always-empty restriction source is safe,
|
||
testable (it should be a structural no-op), and unblocks later work without
|
||
waiting on the open question.
|
||
|
||
**Fixtures/tests to capture BEFORE touching AP-10:** the existing walkable/
|
||
terrain-edge test suite (shoreline walking, any existing water-adjacent
|
||
`TerrainConformanceTests`-family test) — run and record baseline before the
|
||
`0f → 0.1f` change, specifically watching for the float/fall regression
|
||
predicted in §5.2 so it can be attributed correctly (water-depth-caused vs.
|
||
a pre-existing sticky-bit bug newly exposed).
|
||
|
||
**Tests expected to break intentionally:** none identified for AP-71 (the
|
||
gate is a no-op with an empty restriction source); for AP-10, any test
|
||
literally asserting `SampleWaterDepth(...) == 0f` for a dry corner of a
|
||
partially-water cell will need updating to `0.1f`.
|
||
|
||
---
|
||
|
||
## 7. Open questions needing live evidence (not guessed)
|
||
|
||
1. **§1.5** — confirm `CPartArray::GetStepUpHeight`/`GetStepDownHeight`
|
||
actually return `setup->step_up_height * this->scale` (BN mangled the
|
||
FP return into dead stores). Ghidra MCP re-decompile of
|
||
`0x005180d0`/`0x005180f0` once reachable, or a cdb dump of `st0` at
|
||
return.
|
||
2. **§1.5** — retail's step-UP fallback literal (parallel to the confirmed
|
||
`0.0399999991f` step-DOWN fallback) was not independently located; only
|
||
the step-down default was read in the transcribed ranges. Grep
|
||
`CTransition::step_up`'s full body again for a `0.0399999991f`-shaped
|
||
float near the `(state & 2)` gate, or confirm via Ghidra.
|
||
2b. Confirm acdream's `ObjectInfo.StepUpHeight` default of `0.01f`
|
||
(`PhysicsGlobals.DefaultStepHeight`) against whatever retail's actual
|
||
step-up fallback turns out to be (currently AS-CITED it does not match
|
||
the `0.04f` family used everywhere else — worth a dedicated look).
|
||
3. **§2.1** — confirm `CPhysicsObj.state` bit `0x20000` is
|
||
`PhysicsStateFlags.Sledding` by checking `acclient.h`'s `PhysicsState`
|
||
enum directly (not done in this pass; acdream's own prior naming was
|
||
trusted as INFERENCE).
|
||
4. **§2.4 (#165)** — the actual root cause is unresolved; run the
|
||
`ACDREAM_PROBE_RESOLVE`/`ACDREAM_CAPTURE_RESOLVE` capture from §2.4
|
||
against a live repro before writing any fix.
|
||
5. **§4.3 (AP-71)** — the single highest-value open question in this doc:
|
||
is `CObjCell.restriction_obj` a mis-attributed BN field name collision
|
||
between a static DAT-baked count and a live object IID, or does
|
||
`CEnvCell`/`CLandCell` genuinely overwrite the same field at runtime from
|
||
house-related wire data? Needs Ghidra MCP (struct layout + xrefs) or a
|
||
live cdb capture at a real ACE house's locked door.
|
||
6. **§5.1** — the exact retail site of the literal `0.1f` water constant
|
||
(inside `validate_walkable` gated on `isWater`, or elsewhere) was not
|
||
found; ACE's C# port is trusted as the oracle for the FORMULA but not
|
||
for the ORIGINAL retail literal's exact address. Low priority — does not
|
||
block the port (§5.1's conclusion).
|
||
7. **§5.3** — who reads `WATER_CONTACT_TS` (bit 0x8) after
|
||
`SetPositionInternal` writes it. Not found; needs an xref search once
|
||
Ghidra MCP is reachable, or a broader manual read of the pseudo-C for
|
||
`& 8`/`| 8` masks near `transient_state` accesses outside the write site
|
||
already found.
|
||
8. **§5.4** — whether acdream's terrain-collision path already matches
|
||
retail's `ENTIRELY_WATER` + `!ethereal` + `!(state&0x40)` early-exit
|
||
(swim-through-terrain exemption) was not checked; flagged as unverified,
|
||
not as a confirmed match or divergence.
|