feat(physics): port retail projectile integration

Add a pure Core projectile driver for retail clock quanta, final-state acceleration, 50-unit velocity clamping, world-space angular integration, full-3D AlignPath, and oriented Setup-local sphere sweeps. Preserve exact success versus set_frame-only failure semantics, independent Contact/OnWalkable state, and full-cell identity across all landblock directions.

Port OBJECTINFO::missile_ignore with explicit weenie/creature metadata, remove the invented transition-step cap, retain PathClipped without arming PerfectClip, and keep authoritative target identity optional. Add malformed-shape/clock, thin-wall, terrain, target-exclusion, frame-spike, failure-state, and boundary conformance coverage.

Retire TS-2 and synchronize the architecture, milestones, roadmap, research oracle, and durable physics memory.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 11:43:11 +02:00
parent 542dcfc384
commit f02b995e4f
21 changed files with 1669 additions and 263 deletions

View file

@ -146,6 +146,8 @@ src/
PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active)
ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active)
PhysicsEngine.cs -> ResolveWithTransition active player path
ProjectilePhysicsStepper.cs -> pure retail projectile integration + sweep
RetailFrameMath.cs -> retail full-3D vector-heading frame construction
CellBsp.cs -> not a first-class runtime owner yet (L.2e)
World/
GameEntity.cs -> target unified entity, not current reality
@ -216,7 +218,12 @@ InputDispatcher / PlayerMovementController
What exists and is active:
- `PhysicsEngine.ResolveWithTransition` is the path used for local player
collision resolution.
collision resolution and the shared continuous object sweep.
- `ProjectilePhysicsStepper` is the pure Core driver for retail projectile
clock quanta, final-state acceleration, velocity/angular integration,
AlignPath, Setup-sphere transition sweeps, and collision response. It owns no
live entity, network, rendering, or App state; the App controller supplies
those boundaries.
- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
the transition path.
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,

View file

@ -220,7 +220,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
@ -295,8 +294,8 @@ WITH that phase, not before.
11. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
M2 combat must land TS-5 (CanJump gating), TS-23 (PK bits), TS-25
(stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId).

View file

@ -184,7 +184,8 @@ behavior.
catch #103).
- **Camera-collision (shipped 2026-05-30, kept):** retail `SmartBox::update_viewer`
swept-sphere spring arm (`CameraDiagnostics.CollideCamera`, `PhysicsCameraCollisionProbe`,
`RetailChaseCamera` integration) + viewer/sight bypass of the 30-step transition cap.
`RetailChaseCamera` integration); Step 6 later removed the non-retail 30-step
transition cap for every mover after real missile catch-up sweeps proved it invalid.
Specs: [`2026-05-29-a8f-camera-collision-design.md`](../superpowers/specs/2026-05-29-a8f-camera-collision-design.md).
**Today's pre-M1.5 baseline** (2026-05-20 — committed in this
@ -514,6 +515,8 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.
- **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant.
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
**Acceptance:** chat messages display in a retail-style panel, health/stamina/mana orbs fill correctly, attributes panel shows player stats, inventory opens with drag-drop working, and sound plays on hit/footstep.

View file

@ -471,7 +471,7 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade
preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **M2/M3 missile, effect, and portal presentation campaign (Steps 05
- **M2/M3 missile, effect, and portal presentation campaign (Steps 06
implemented and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and
@ -509,9 +509,15 @@ include dungeons.
and per-child pose buffers are reused. Rigid attachment/effect frames exclude
visual Setup scale, attachment withdrawal cascades through descendants, and
later pose publication recovers the complete descendant chain parent-first;
light registration follows final projection visibility. AP-67, TS-10, TS-11, and TS-12 are
retired. The remaining steps port projectile motion and
sweeps, and Hidden/UnHide portal presentation.
light registration follows final projection visibility. Step 6 adds the pure
Core retail projectile driver: 0.2-second catch-up quanta, the 50-unit/second
clamp, final-state gravity, world-space omega, full-3D AlignPath, scaled
Setup-local sphere sweeps, exact missile target/ethereal exclusions,
self-shadow rejection, retail collision response, and full-cell landblock
rebasing. Missile adds PathClipped but never PerfectClip. AP-67, TS-2, TS-10,
TS-11, and TS-12 are retired; AP-83/AP-91 remain dormant. The remaining
steps integrate live projectile ownership/corrections and port Hidden/UnHide
portal presentation.
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)**
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -5,12 +5,12 @@
This document extracts the per-tick variable-dt substepping algorithm and the Frame composition primitives that drive the per-frame physics integration. It answers:
- What is the substepping algorithm? (HugeQuantum discard → MaxQuantum slicer loop → MinQuantum remainder)
- What happens for very small dt? (early-return at EPSILON, NOT MinQuantum — retail processes every frame)
- How is `LastUpdateTime` advanced? (always to `PhysicsTimer::curr_time` after the loop)
- What happens for very small dt? (micro-fragments at EPSILON are consumed; remainders at or below MinQuantum accumulate)
- How is `LastUpdateTime` advanced? (to the consumed `PhysicsTimer::curr_time`, not unconditionally to the wall clock)
- What does `process_hooks` do? (iterates linked-list of `PhysicsObjHook`s + `anim_hooks` per frame, executing & removing finished ones)
- `Frame::combine` semantics: `out = a · b` — Frame transform composition (rotate b's origin by a's basis, then add a's origin; quaternion product `a.q * b.q` for orientation).
The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible** in the BinaryNinja decompiled `update_object` because the BN decompiler corrupted some immediate floats to `0.0`. The constants are recovered cleanly from ACE's `PhysicsGlobals.cs`, which itself is a faithful port of the same retail binary. The `0.000199999995f` (= `EPSILON = 0.0002`) constant IS visible in the decomp (line 283996) — it's the early-exit tolerance distinct from `MinQuantum`.
The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible** in the BinaryNinja decompiled `update_object` because the BN decompiler corrupted some global loads to `0.0`. Matching-client disassembly resolves them as 1/30 second, 0.2 seconds, and 2.0 seconds. The `0.000199999995f` (= `EPSILON = 0.0002`) constant IS visible in the decomp (line 283996) — it is the micro-fragment tolerance distinct from `MinQuantum`.
---
@ -72,13 +72,13 @@ The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible
// ── Substep loop: while dt > MaxQuantum, slice off MaxQuantum chunks ─────
// BN corrupted MaxQuantum to "0.0" in the loop body, but the loop structure
// is unmistakable (line 284031 do-while). ACE's port confirms MaxQuantum=0.1.
if (dt > 0.0f /* MaxQuantum=0.1 */) {
// is unmistakable (line 284031 do-while). Matching disassembly resolves 0.2.
if (dt > 0.0f /* MaxQuantum=0.2 */) {
do {
PhysicsTimer::curr_time += /* MaxQuantum */ 0.1;
CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.1f);
dt -= /* MaxQuantum */ 0.1;
} while (dt > /* MaxQuantum */ 0.1);
PhysicsTimer::curr_time += /* MaxQuantum */ 0.2;
CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.2f);
dt -= /* MaxQuantum */ 0.2;
} while (dt > /* MaxQuantum */ 0.2);
}
// ── Final remainder: if dt > MinQuantum (1/30), simulate the leftover ────
@ -94,18 +94,18 @@ The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible
}
```
### Constants — recovered values
### Constants — matching-client values
From `references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9-43`:
Recovered from the v11.4186 matching executable and cross-checked against the named control flow:
| Symbol | Hex (float32) | Value | Meaning |
|---|---|---|---|
| `EPSILON` | `0x3949A18A` | `0.000199999995f` ≈ 0.0002 s | "essentially zero" tolerance (visible in retail decomp line 283996) |
| `MinQuantum` | `0x3D088889` | `1.0f / 30.0f ≈ 0.03333` s (30 fps) | minimum simulation step |
| `MaxQuantum` | `0x3DCCCCCD` | `0.1f` (10 fps) | substep cap — BN-corrupted to 0.0 in pseudo-C but confirmed via ACE |
| `MaxQuantum` | `0x3E4CCCCD` | `0.2f` (5 fps) | catch-up substep cap — BN-corrupted to 0.0 in pseudo-C |
| `HugeQuantum` | `0x40000000` | `2.0f` (0.5 fps) | upper bound — beyond this, dt is discarded as stale (visible line 284009) |
**Note on the BN-decomp corruption:** lines 284034, 284036-284037, 284049 in the pseudo-C show `((long double)0.0)` where retail clearly reads non-zero immediates from `.rdata`. The decompiler dropped the immediate during constant-folding when sourcing from a global. Cross-reference with ACE confirms these are MaxQuantum=0.1 in the loop body and 0.0333 in the final-remainder guard.
**Note on the BN-decomp corruption:** lines 284034, 284036-284037, 284049 in the pseudo-C show `((long double)0.0)` where retail reads non-zero globals. Matching-client disassembly confirms these are MaxQuantum=0.2 in the loop body and MinQuantum=1/30 in the final-remainder guard. ACE's server-side 0.1-second value is not the retail-client oracle here.
### `update_object_server` — does it exist?
@ -660,20 +660,33 @@ Sets heading from a yaw angle (degrees):
### `Frame::set_vector_heading` (FUN_00535DB0) — line 320030
Sets heading to face a normalized 2D direction vector (rotation around Z-axis):
Replaces the frame rotation so local forward (+Y) faces a full three-dimensional
direction with zero roll. A zero/small vector leaves the existing rotation
unchanged. The named lift preserves the X/Y compass calculation and Z-driven
`asin` elevation path, although it mangles the `euler_set_rotate` argument list:
```
00535db0 void Frame::set_vector_heading(Frame* this, Vector3 const* dir) {
Vector3 d = *dir;
if (AC1Legacy::Vector3::normalize_check_small(&d) != 0) return;
if (AC1Legacy::Vector3::normalize_check_small(&d) != 0)
return; // preserve the current frame
// Note: AC's heading convention — angle from north (+Y) measured clockwise.
// 450 - atan2(x, y) normalizes to [0, 360).
double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323;
yawDeg = fmod(yawDeg, 360.0);
Frame::euler_set_rotate(this, ..., 0, ..., yawDeg * 0.017453292519943295);
// Full 3-D elevation; not a yaw-only rotation. The retail Euler convention
// places normalized d exactly on the resulting frame's local +Y axis.
double elevation = asin(d.z);
Frame::euler_set_rotate(this, zeroRoll, elevation, yawDeg * DEG_TO_RAD);
}
```
The conformance contract is therefore `transform(UnitY, rotation) == normalize(dir)`
for horizontal, elevated, downward, and near-vertical vectors. The exact-vertical
case uses retail's deterministic zero-compass fallback.
### `Frame::rotate` (FUN_004525B0) — line 91477
Applies a small rotation increment in local space (used by omega integration):
@ -763,11 +776,16 @@ Note: the BN decomp shows `result->z; result->y; result->x;` followed by `return
```
dt = currentTime - LastUpdateTime
PhysicsTimer.current = LastUpdateTime
if (dt < EPSILON) return; // < 0.0002s too small, defer
if (dt > HugeQuantum) return; // > 2.0s — stale, discard, advance update_time
if (dt <= EPSILON): // <= 0.0002s — micro-fragment
LastUpdateTime = currentTime // consume without simulation
return
if (dt > HugeQuantum): // > 2.0s — stale gap
LastUpdateTime = currentTime // discard without simulation
return
while (dt > MaxQuantum): // 0.1 s
while (dt > MaxQuantum): // 0.2 s
PhysicsTimer.curr_time += MaxQuantum
UpdateObjectInternal(MaxQuantum)
dt -= MaxQuantum
@ -781,38 +799,24 @@ LastUpdateTime = PhysicsTimer.curr_time
**Observations:**
1. **Retail processes every frame**, not just every 30 Hz. The first guard is `EPSILON`, not `MinQuantum`. ACE flipped this to `< TickRate` (= 1/30) for server CPU savings — that's a divergence, not retail-faithful.
2. **dt below MinQuantum but above EPSILON → no simulation that frame, but `update_time` IS advanced** to current time (line 284004). That means the next frame's dt is small again — accumulation is implicit, not explicit. There is no carry-over residue.
3. **Between MinQuantum and MaxQuantum: a single substep** for the full dt. (60-fps client => dt ≈ 0.0167 s — wait, that's BELOW MinQuantum.) **Actually at 60 fps the second guard (`> MinQuantum`) is also FALSE, so nothing runs.** This is consistent with retail running its physics tick at 30 Hz in `MainProc`. Retail's `MainProc` ticks at ~30 Hz on most hardware, not 60 Hz. acdream renders at 60 Hz but ticks physics at... whatever wall-clock dt comes through.
4. **Between MaxQuantum and HugeQuantum: chunked substepping at fixed 0.1 s, then 1 final remainder if it exceeds MinQuantum.** This handles frame-stutter / lag spikes (e.g. world load).
1. **The EPSILON branch and MinQuantum remainder are different.** A true micro-fragment is consumed immediately. A remainder above EPSILON but at or below 1/30 second runs no simulation and leaves `PhysicsTimer.current` at the prior update time, so it accumulates into the next call.
2. **At a 60 Hz render rate**, the first ~0.0167-second call remains accumulated; the next call sees ~0.0334 seconds and executes one physics quantum. This yields the retail ~30 Hz physics cadence without an explicit accumulator field.
3. **Between MinQuantum and MaxQuantum**, retail executes one substep for the full elapsed time.
4. **Between MaxQuantum and HugeQuantum**, retail replays fixed 0.2-second catch-up quanta, then one final remainder only when it exceeds MinQuantum.
5. **`process_hooks` runs once per substep** (inside `UpdatePositionInternal`), so its rate scales with substep count — important for FPHook fade timers.
6. **The collision sweep (`transition`) runs once per substep**, which is why bypassing it (env-var path bug) caused the "staircase" effect on slopes.
### What this means for `LastUpdateTime` advancement
After every substep loop, `LastUpdateTime = PhysicsTimer.curr_time`. `PhysicsTimer.curr_time` was incremented inside the loop by `MaxQuantum` per substep + the final remainder. **It accumulates ONLY consumed time** — if the early-`< EPSILON` guard fires, `update_time` is set to `Timer::cur_time` directly, dropping any unconsumed micro-fragment.
After every substep loop, `LastUpdateTime = PhysicsTimer.curr_time`. It advances only by simulated quanta. The EPSILON and HugeQuantum guards instead assign `Timer::cur_time` directly because those fragments are deliberately consumed or discarded.
---
## 14 — Cross-check against acdream's port
**`PhysicsBody.update_object` (`src/AcDream.Core/Physics/PhysicsBody.cs:404-435`):**
**`PhysicsBody.update_object` (`src/AcDream.Core/Physics/PhysicsBody.cs`)** now carries the same clock contract: EPSILON/HugeQuantum guards consume wall-clock time, the 0.2-second loop advances a local physics clock, and a remainder at or below MinQuantum remains accumulated. `ProjectilePhysicsStepper` applies the same clock around the full integration-and-transition sequence.
acdream uses the threshold values correctly (MinQuantum=1/30, MaxQuantum=0.1, HugeQuantum=2.0) and has the substep loop. **But it gates on `dt < MinQuantum` for the early return**, not `< EPSILON`. This matches ACE's port (which uses TickRate=1/30) but **diverges from retail**, which uses EPSILON=0.0002.
**Practical effect of the divergence:**
At 60 fps (dt = 0.0167 s), retail would: pass the EPSILON gate → fail the loop gate (0.0167 < MaxQuantum=0.1) fail the remainder gate (0.0167 < MinQuantum=0.0333) bump `update_time` and return. **No simulation ran, but time was consumed.** Next frame: dt = 0.0167 again. Retail effectively sub-samples to 30 Hz, but does it through threshold-based skipping rather than explicit accumulation. Net effect: physics ticks at ~30 Hz on a 60-Hz render loop.
acdream's port: at 60 fps, dt = 0.0167 s, fails first gate (`< MinQuantum` = 0.0333), returns immediately WITHOUT updating `LastUpdateTime`. **Next frame: dt = 0.0334 s**, passes the first gate, runs `UpdatePhysicsInternal(0.0334)` once. Net effect: physics ticks at ~30 Hz, same outcome, but via accumulation. Equivalent functionally; minor structural divergence.
Recommendation: the acdream port is fine as-is for acdream (no behavioral difference at 30 Hz target), but the comment at line 395 (`if dVar1 < MinQuantum → return`) should note that retail uses EPSILON; the change is intentional alignment with ACE's optimization.
**`GameWindow.cs` per-tick remote motion path (line 6541-6553):**
The comment "rely on `PhysicsBody.update_object` here — its MinQuantum 30 fps gate" is **accurate about acdream's port** but **slightly misleading about retail's behavior**. Retail does NOT have a 30 Hz gate in `update_object`; it has a substep loop that effectively delivers 30 Hz simulation by way of the inner thresholds. The manual omega integration in GameWindow (line 6553-6559) is a workaround for a different reason — the body's quaternion-omega integration doesn't run when the body's update_object's loop body doesn't run, but acdream's render-tick path needs orientation continuity at 60 fps. This is a legitimate divergence forced by the env-var-path architecture; it's not a retail mismatch.
**The real bug (Commit B fix at line 6190):** the env-var path was missing the `ResolveWithTransition` call (port of `find_transitional_position`) that retail runs once per substep. Restoring it (line 6220) brought the env-var path back in line with retail.
The live projectile path does not use the older render-only remote-motion integration. It invokes a transition for every consumed quantum, matching `UpdateObjectInternal`, so a catch-up frame cannot tunnel merely because several quanta were required.
---
@ -820,5 +824,4 @@ The comment "rely on `PhysicsBody.update_object` here — its MinQuantum 30 fps
- **`Frame::set_origin`** and **`Frame::is_zero`** don't exist as named symbols. The conventions are: direct field write for origin, and `Vector3::is_zero` on `frame.m_fOrigin` for the test. Confirm acdream's port uses the same conventions (no need for these methods on the `Frame` type).
- **`update_object_server` does not exist.** ACE's distinction between client and server update is not present in retail. The retail client and the retail server (which acdream emulates) probably both run the same code path; if so, acdream needs only one `update_object`.
- **The early-return gate divergence (EPSILON vs MinQuantum)** is functionally invisible at 30 Hz physics target but worth documenting in the port comment so future readers know it's an intentional ACE-style optimization, not a retail-faithful copy.
- **`process_hooks` linked-list (`PhysicsObjHook`)** is not yet ported in acdream. acdream has anim-hook routing (`AnimationHookRouter`) but no equivalent of FPHook / TranslucencyHook / VisibilityHook persistent linked-list framework. Phase that uses translucency animations or scale fades will need this.

View file

@ -821,3 +821,54 @@ observable ordering without exposing a half-committed pose to modern sinks.
through normal owner teardown while retaining DAT-static/synthetic poses.
TS-11 and TS-12 are retired with the emitter-binding and
live-part mechanisms.
## 15. Step 6 implementation map (2026-07-14)
- `ProjectilePhysicsStepper` is a pure Core clock/integration/sweep owner. It
accepts an already-live `PhysicsBody`, full cell, Setup-local sphere, local
entity identity, and optional authoritative target identity; it owns no
renderer, network state, or logical lifetime. `ProjectileController` in the
next step will select records and apply authoritative corrections around it.
- The shared integrator now uses retail's 0.2-second maximum catch-up quantum
and world-space angular rotation (`Frame::grotate`, quaternion delta
pre-multiplied). Velocity is clamped to 50 world units/second before
integration, acceleration is recalculated from the final PhysicsState each
quantum, and AlignPath replaces orientation from the complete three-axis
displacement through `RetailFrameMath.SetVectorHeading`.
- Installed projectile Setup auditing proves the required arrow, bolt, and
representative spell set uses exactly one ordinary collision sphere. The
port retains the complete Setup-local origin and scales both origin and
radius; it therefore preserves Force Bolt's offset
`(0, -0.165, 0.1045)` and never derives collision size from the mesh.
- `ResolveWithTransition` accepts the sphere's local origin and begin/end
orientations, carries the resulting orientation in `ResolveResult`, skips
the moving entity's own shadow, and automatically adds PathClipped for a
Missile body. It deliberately does not add PerfectClip.
- `ObjectInfo.MissileIgnore` is the exact `0x0050CEB0` branch tree. Runtime
collision metadata now distinguishes “has a CWeenieObject” from “is a
creature”; server-created live entities set the former, while DAT landblock
geometry does not. The designated target remains zero until an
authoritative wire/runtime source supplies one.
- Transition subdivision provides the continuous sweep. Conformance covers
maximum-speed thin sphere and BSP walls, terrain floors, elastic reflection,
inelastic stopping, missiles, ethereal weenies, designated and unknown
creature targets, self exclusion, and loaded landblock crossing. Retail's
transition loop has no arbitrary step-count cap; a 50-unit/second arrow with
a 0.1-unit sphere therefore completes all 100 sweep steps in one 0.2-second
catch-up quantum. On transition failure, the integrated candidate frame is
retained in the current cell and cached velocity is zeroed without running
`SetPositionInternal` or collision response; transition-local contact,
stationary, walkable-polygon, and sliding results are discarded. Successful
`SetPositionInternal` writeback keeps Contact distinct from OnWalkable: a
steep plane can be contact while only normals with Z at or above FloorZ are
walkable. Both facts commit before collision response, so a floor impact
settles on the following tick without turning a steep hit into ground.
- The landblock-crossing test exposed and fixed a shared world-frame rebasing
defect: after the containing cell crosses a 192-metre block boundary, the
carried block origin must move by the same block delta before the next
substep. Retail gets this for free from cell-relative `Position.Frame`;
acdream now performs the equivalent rebase at the ordered containing-cell
retarget, preventing one extra landblock jump per remaining sweep substep.
Boundary conformance covers positive and negative X and Y crossings.
- TS-2 is retired. AP-83 and AP-91 remain because ordinary missiles are
PathClipped and do not arm their dormant PerfectClip time-of-impact paths.