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

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