Compare commits

..

No commits in common. "77b59d89e22d464d7285a2b0cb823caf110f3da6" and "1abb699c6805ab00f7ca515cdedac2912310fca2" have entirely different histories.

20 changed files with 64 additions and 2423 deletions

23
.gitignore vendored
View file

@ -41,26 +41,3 @@ tmp/
# Git worktrees for isolated feature work
.worktrees/
# Per-session retail-debugger scratch — cdb scripts, logs, analysis helpers.
# The committed reference workflow lives in CLAUDE.md "Retail debugger toolchain";
# session-specific traces should not pollute the repo.
*.cdb
launch_*.log
launch_*.err
launch_*.ps1
launch[0-9]*.log
analyze_*.ps1
peek_*.ps1
run_cdb_*.ps1
find_cdb.ps1
find_acclient.ps1
kill_cdb.ps1
append_memory.ps1
sky_*.log
smoke_test*
steep_roof_trace*
substep_trace*
sg_built.txt
# Stray bash-mangled path artifacts from PowerShell-via-bash escaping
C[€-￿]*

177
CLAUDE.md
View file

@ -112,51 +112,6 @@ a phase just landed, and move to the next todo item.
always yes — keep going.** The single exception is visual verification;
otherwise, act.
## Communication style
The user is a strong systems / C# / network programmer but **less
practiced at 3D math, physics, graphics, and animation**. They want
to learn — they're not asking for dumbed-down content, but for
explanations that build understanding alongside the work.
When discussing 3D / physics / graphics / animation / dat-format /
protocol-internals topics:
- **Name the concept in plain language first**, then introduce the
term of art. "The angle of a slope (we call its straight-up
component `Normal.Z`)" rather than dropping `normal.Z = cos θ`
with no anchor.
- **Give units**: degrees, meters, cm — NOT raw floats. "FloorZ ≈
0.66 means slopes up to about 49° are walkable" rather than
"FloorZ = 0.66417414f". Floats are for the code; English is for
the conversation.
- **Use analogies for spatial concepts** when they fit. A BSP tree
is "a way of slicing space into nested rooms"; a contact plane is
"the imaginary floor under the player's feet"; a sphere sweep is
"rolling a ball forward through space and stopping it on contact";
a cross product is "the direction perpendicular to two arrows";
a dot product is "how aligned two arrows are (1 = same, 0 =
perpendicular, -1 = opposite)".
- **Don't pile on multiple new concepts in one paragraph.** If a
problem touches step-up AND step-down AND edge-slide AND
walkable-polygon tracking, walk through them one at a time, each
with what it does and why it exists.
- **Show the math when it matters, but explain it.** Don't just
drop a formula and move on; tag it with "what this means
geometrically".
- **Use frame-by-frame walk-throughs** for control-flow-heavy
physics: "frame N: player here, lands. Frame N+1: state checks…"
beats a function-call trace for understanding what's happening
in motion.
- **Flag terms of art** the first time they appear in a session,
even if they're sprinkled through code comments. "Broadphase",
"BSP", "step-up", "ContactPlane", "ValidateWalkable" — they
earn their meaning the first time you spell it out.
The goal is collaborative learning. Don't simplify the content; just
make sure every term and number is grounded so the user can keep up
and build intuition over time.
## Development workflow: grep named → decompile → verify → port
**This is the mandatory workflow for implementing ANY AC-specific behavior.**
@ -164,22 +119,10 @@ The triangle-boundary Z bug cost 5 failed fix attempts from guessing.
The animation frame-swap bug cost 4 failed attempts. Every time we
checked the decompiled code first, we got it right on the first try.
**Now we have named retail symbols too — Step 0 cuts most lookups
from 30 minutes to 5 seconds. And as of 2026-04-30, when "what does
retail actually DO at runtime?" is the question and decomp alone
isn't enough, attach cdb to a live retail client (Step -1).**
from 30 minutes to 5 seconds.**
### For each new feature or bug fix:
-1. **ATTACH cdb TO RETAIL (when behavior is the question, not code).**
For "what does retail actually DO frame-by-frame?" questions —
wedges, weird animation flicker, geometry-specific bugs, anything
where the decomp is correct but it's not clear how it produces the
visible behavior — **don't guess; attach the Windows debugger to
a live retail client and trace it.** See "Retail debugger toolchain"
below for setup. We discovered the steep-roof wedge had a 30Hz
physics-tick cause this way; would have taken weeks of guessing
without the trace.
0. **GREP NAMED FIRST.** Before any decompilation work, search
`docs/research/named-retail/acclient_2013_pseudo_c.txt` by
`class::method` name. 99.6% of functions have real names from the
@ -261,124 +204,6 @@ Before marking any phase as done:
- [ ] Roadmap updated
- [ ] Memory updated if there's a durable lesson
## Retail debugger toolchain (live runtime trace)
**When the question is "what does retail actually DO frame-by-frame?"**
the decomp alone is often not enough — code paths interact with state
(LastKnownContactPlane, transient flags, accumulated counters) in ways
that aren't obvious from reading. As of 2026-04-30 we have a working
toolchain to attach Windows' console debugger (cdb.exe) to a live
retail acclient.exe with full PDB symbols and capture state at any
breakpoint. **Use this when guessing has failed twice in a row.**
### What we have
- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe`
v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs
exactly with our `refs/acclient.pdb`.
- **Debugger**: `cdb.exe` (console WinDbg) at
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
Install via Microsoft Store WinDbg (~50 MB). 32-bit version is
required for acclient.exe.
- **PDB**: `refs/acclient.pdb` (29 MB, Sept 2013 EoR build).
18,366 named functions + 5,371 named struct types resolve.
- **Symbol verifier**: `tools/pdb-extract/check_exe_pdb.py <exe>`
reads any acclient.exe and prints whether it pairs with our PDB
(`MATCH` / `MISMATCH (expected GUID = ...)`). Always run this on
a candidate binary BEFORE attaching.
- **PDB metadata dumper**: `tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb`
prints the PDB's expected timestamp + GUID + age. Use to figure
out which build to look for if the chain ever breaks.
### Workflow
1. **Verify the binary matches the PDB:**
```bash
py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"
```
Expect: `=== MATCH: this exe pairs with our acclient.pdb ===`
2. **Have the user launch retail client** and connect to local ACE.
Retail must already be in-world before attaching.
3. **Write a `.cdb` script** that arms breakpoints with non-blocking
actions (count + log + `gc`). Pattern:
```
.logopen <output-path>
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
bp acclient!CTransition::transitional_insert "r $t0 = @$t0 + 1; .if (@$t0 % 5000 == 0) { .printf \"...\" }; .if (@$t0 >= 30000) { qd } .else { gc }"
bp acclient!OBJECTINFO::kill_velocity "r $t1 = @$t1 + 1; gc"
...
g
```
`gc` = "go conditional" (continue without breaking). Auto-detach
via `qd` after a hit-count threshold to avoid manual cleanup.
4. **Launch cdb in the background** via a PowerShell wrapper:
```powershell
& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" `
-pn acclient.exe -cf <script>.cdb *>&1 |
Tee-Object -FilePath <log>
```
5. **User reproduces the scenario** in the retail client window
(jump on roof, hit wall, etc.). Breakpoints fire, log fills.
6. **cdb auto-detaches** when the threshold breakpoint fires `qd`.
Retail keeps running unaffected. Read the log offline.
### Known watchouts
- **PDB function names use snake_case for some classes**
(BSPTREE, CTransition, OBJECTINFO, COLLISIONINFO, SPHEREPATH) and
**PascalCase for others** (CPhysicsObj). The Binary Ninja decomp
shows snake_case for everything; the PDB has Turbine's actual
PascalCase for CPhysicsObj. Always look up symbols with `x` first
to find the actual name.
- **`bp acclient!Class::method`** sets a breakpoint by symbol. The
cdb command parser splits on `;`, so don't put `;` inside the
action string — use newlines or escape carefully.
- **Symbol path: do NOT use `.sympath srv*<server>;<local>`** — the
`;` is a cdb command separator, gets split. Use `.sympath <local>`
(no symbol server, just our refs/) since we don't need Microsoft
system DLL symbols.
- **Killing cdb kills the debuggee.** Use `qd` (quit detached) inside
a breakpoint action to detach cleanly. `Stop-Process cdb` will
take the retail client down with it.
- **High breakpoint hit rates produce game lag.** Each breakpoint hit
traps the process briefly. For frequent functions
(transitional_insert at ~10K/sec) the cumulative cost is enough to
make retail feel sluggish. Mitigate by setting a tight auto-detach
threshold (e.g., 30,000 hits) and/or moving counters to less-frequent
functions.
- **acclient.exe is 32-bit + uses thiscall.** When dumping struct
fields in breakpoint actions, `this` is in `ecx`. Use cdb's
`dt acclient!ClassName @ecx` for full struct dump.
### When NOT to use this
- **Pure code-port questions** — the decomp at `docs/research/named-retail/`
has the answer. Don't waste time on cdb if `grep` is enough.
- **Visual / rendering bugs** — debugger doesn't help with shaders or
framebuffers; use RenderDoc or similar.
- **Network protocol questions**`holtburger` references + ACE source
+ Wireshark are the right tools, not cdb.
This toolchain was used to settle the L.5 steep-roof investigation:
30Hz physics tick (vs our 60Hz), `kill_velocity` gating,
`set_collide` rate per minute. See commit history around 2026-04-30
for the trace data and the decisions it drove.
## Subagent policy
Subagents are the primary tool for saving parent-context and keeping one

View file

@ -46,164 +46,6 @@ Copy this block when adding a new issue:
# Active issues
## #38 — Chase camera + player feel "30 fps" since L.5 physics-tick gate
**Status:** OPEN
**Severity:** MEDIUM (gameplay-feel regression; not a correctness bug)
**Filed:** 2026-05-01
**Component:** rendering / physics / camera
**Description:** User reports that running around in third-person /
chase camera feels less smooth than it did before the L.5 physics-tick
work. FPS counter still reads 60+, but the *motion* of the player
character + camera looks like it's updating at ~30 fps.
**Root cause / status:**
Almost certainly the L.5 `_physicsAccum` gate in
`PlayerMovementController.cs` (lines ~448-456). Retail integrates
physics at 30 Hz (`MinQuantum = 1/30 s`); we ported that faithfully so
collision behavior matches. Side effect: `_body.Position` only updates
on physics ticks, i.e. every 33 ms. Render runs at 60+ Hz but the
chase camera follows `_body.Position` directly — so the *visible*
position changes in 33 ms steps, even though we render at 60+ FPS.
First-person is less affected because the world rotates with Yaw (which
*does* update every render frame); third-person is hit hardest because
the character itself is the moving thing.
Retail in 2013 didn't see this because render was also ~30 fps —
render rate ≈ physics rate. Our 60+ Hz render exposes the gap.
Discussion + fix options at the end of `docs/research/2026-05-01-retail-motion-trace/findings.md`
("Other things still don't have…" → camera smoothness discussion in
chat, not yet captured in the doc — TODO migrate the discussion in).
Recommended fix: **render-time interpolation between physics ticks**
(standard fixed-timestep + interpolated rendering pattern from Quake /
Source / Unreal). Snapshot `_prevPhysicsPos` and `_currPhysicsPos` at
each tick; render player + camera target at
`Lerp(_prev, _curr, _physicsAccum / PhysicsTick)`. Cost: ~33 ms visual
latency between input and what you see (matches retail's perceived
latency anyway). Network outbound stays on the discrete tick value —
no wire change.
Quick confirmation test before any code change: temporarily set
`PhysicsTick` to `1.0/60.0` and see if chase camera feels smooth again.
If yes, gate is confirmed cause. (Don't ship that — it'd undo the L.5
collision fixes.)
**Files:**
- `src/AcDream.App/Input/PlayerMovementController.cs:172``PhysicsTick` constant
- `src/AcDream.App/Input/PlayerMovementController.cs:448-456``_physicsAccum` gate
- `src/AcDream.App/Rendering/GameWindow.cs` — wherever player render position + chase camera read `_body.Position`
**Research:**
- L.5 background: `memory/project_retail_debugger.md` (the 30 Hz
MinQuantum gate, the cdb trace evidence)
- Discussed during 2026-05-01 motion-trace work
**Acceptance:**
- Chase-camera run-around at 60+ FPS feels as smooth as render rate
suggests (no perceptual stepping)
- Network outbound (MoveToState / AutonomousPosition cadence + values)
unchanged from current behavior
- Collision behavior unchanged (the L.5 wedge / steep-roof scenarios
still resolve correctly)
- Observer view from a parallel retail client unchanged
## #37 — Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
**Status:** OPEN
**Severity:** LOW (cosmetic; doesn't affect gameplay)
**Filed:** 2026-05-01
**Component:** rendering / clothing / textures
**Description:** Every humanoid character (player + NPCs) wearing a coat
shows a visible skin-colored region at the top of the coat where retail
shows continuous coat fabric. From the back view: hair → skin stub →
coat top. In retail: hair → coat collar (no exposed skin). This was
originally reported as "head/neck protruding forward" — the apparent
forward shift is an optical illusion caused by the missing coat collar.
**Investigation 2026-05-01 (~3 hr session, conclusively ruled out
many hypotheses):**
What we ruled out:
- **Animation source.** `ACDREAM_USE_PLACEMENT_BASE=1` (force chars to
`Setup.PlacementFrames[Resting]` instead of `Animation.PartFrames[0]`)
→ stub still visible.
- **Backface culling / mesh winding.** `ACDREAM_NO_CULL=1` (disable
`glCullFace` entirely) → stub still visible.
- **Palette overlay (SubPalettes).** `ACDREAM_NO_PALETTE_OVERLAY=1`
(skip `ComposePalette`) → stub still visible (other colors broke
as expected — confirms overlay was firing). Bug is NOT a body-skin
SubPalette being mis-applied to coat fabric.
- **Bug source = part 16 (head).** `ACDREAM_HIDE_PART=16` → head goes
away, stub remains UNCHANGED (clean coat top with same shape).
Stub is NOT from head GfxObj polygons.
- **Per-part placement frame Origin.** `ACDREAM_NUDGE_Y=-0.1` confirmed
`+Y = forward` in body-local; head Origin (0, 0.013, 1.587) places
head correctly relative to spine. Math checks out.
What we confirmed (data is correct):
- Player Setup `0x02000001` (Aluvian Male), 34 parts.
- Server (ACE) sends `animParts=34 texChanges=12 subPalettes=10`.
- Part 9 (upper torso/coat) has gfx `0x0100120D` after AnimPartChange.
- Part 9 has 2 surfaces, BOTH covered by 2 TextureChanges
(`oldTex=0x050003D5→0x05001AFE`, `oldTex=0x050003D4→0x05001AFC`).
- Stub IS from part 9: `ACDREAM_HIDE_PART=9` → entire torso (including
stub region) disappears.
- Per-part composition formula (`Scale × Rotation × Translation`)
matches ACME's `StaticObjectManager.cs:256-258` and retail decomp's
`Frame::combine` at `0x00518FD0`.
**Remaining hypothesis space (untested):**
1. **Texture decode produces skin pixels** for `0x05001AFE/0x05001AFC`
where ACME / retail produces coat pixels. Compare our SurfaceDecoder
against ACME's `TextureHelpers.cs` for INDEX16 / palette-indexed
chains.
2. **Polygon-to-surface mapping off-by-one.** Specific polygons of
part 9 reference an unintended surface. Add a dump: for each polygon
in gfx 0x0100120D, print `PosSurface` index + the resolved Surface id.
3. **Multi-layer texture composition retail does and we skip.** AC's
"ApplyCloth" or similar layered texture step. Grep
`acclient_2013_pseudo_c.txt` for `BlendBaseLayer`, `LayerSurfaces`,
any composition method that combines multiple textures into one.
4. **UV mapping bug.** Part 9's polygon UVs map to a skin region of
the coat texture. Dump per-vertex UV vs vertex Z; if a high-Z vertex
has UV.v near a skin region, that's the source.
**Files (diagnostic env vars committed for next-session reuse):**
- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
`ACDREAM_NO_CULL` env var
- `src/AcDream.App/Rendering/GameWindow.cs``ACDREAM_HIDE_PART=N`
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
AnimPartChanges + TextureChanges + per-part Surface chain coverage.
- `src/AcDream.App/Rendering/TextureCache.cs:159-204``DecodeFromDats`
is the texture decode entry. Compare against
`references/WorldBuilder-ACME-Edition/.../TextureHelpers.cs`.
**Reproduction:**
```powershell
$env:ACDREAM_LIVE = "1"; $env:ACDREAM_DEVTOOLS = "1"
# normal launch — visible from chase camera looking at +Acdream's back
```
Stub is visible on +Acdream and on every NPC humanoid (Pathwarden,
Town Crier, Shopkeeper Renald, etc.).
**Acceptance:** Side-by-side retail + acdream rendering of +Acdream
shows coat extending up to chin level on both. No exposed skin
between hair and coat.
## #L.1 — Hotbar UI panel
**Status:** OPEN
@ -350,48 +192,12 @@ hard-blocks or accepts too much in several of these cases.
`step_up_slide` now feels acceptable in live testing. Local/remote movement
passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
before `SPHEREPATH::precipice_slide`; edge-slide `Slid` / `Adjusted` results
now feed the `TransitionalInsert` retry loop instead of being reverted by outer
validation, and a synthetic diagonal terrain-boundary test covers tangent
motion. `ACDREAM_DUMP_EDGE_SLIDE=1` now reports whether a failed step-down had
polygon context.
**L.4/L.5 update 2026-04-30:** A retail debugger trace (cdb attached to
v11.4186 acclient.exe — see #35) confirmed that retail does NOT wedge
on the steep-roof scenario that produces the wedge in our acdream port.
Three concrete findings:
1. Retail's `OBJECTINFO::kill_velocity` rarely fires in normal play —
gated on `last_known_contact_plane_valid`, which our L.2.4 proximity
guard tends to clear before steep-poly hits land. Retail trace: 0
kill_velocity hits across 40,960 update_object calls. Our Phase 3
reset path now matches retail's gate (only kills when valid).
2. Retail integrates physics at 30Hz (`MinQuantum = 1/30 s`); render is
60+ Hz. UpdatePhysicsInternal/update_object ratio = 0.61. We
ported this gate as L.5 in `PlayerMovementController` via
`_physicsAccum`. Render still runs at 60+ Hz; only the physics
integration step is 30Hz.
3. The remaining wedge cause — body's pre-position drifts to the
polygon's tangent and gravity's tangent component into surface
produces a stable retain-collide-revert loop — is a downstream
consequence of retail's grounded-on-steep escape chain
(`step_sphere_up``step_up_slide``cliff_slide`) being
incompletely ported. Live test confirmed retail-strict Path 6
produces "lands on roof in falling animation, can't slide off"
half-state because that chain doesn't produce smooth descent.
**Pragmatic ship-state:** BSPQuery Path 6 keeps the L.4 slide-tangent
deviation (project-along-steep-face-and-return-Slid) for steep-poly
airborne hits. It produces user-acceptable "slide off the roof"
behavior at the cost of departing from retail's Path 6 → SetCollide →
Path 4 → Phase 3 reset chain. Retail-strict requires the
step_up_slide / cliff_slide audit below; until that lands, slide-tangent
is the right deviation.
Remaining gaps: real-DAT building-edge fixtures, fuller `cliff_slide`
coverage, `NegPolyHit` dispatch, and the retail-strict
step_up_slide / cliff_slide audit (filed for follow-up). Named retail
anchors include `CTransition::edge_slide`, `CTransition::cliff_slide`,
`SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`.
before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports
whether a failed step-down had polygon context. Remaining gaps: real-DAT
building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit`
dispatch. Named retail anchors include `CTransition::edge_slide`,
`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
`SPHEREPATH::step_up_slide`.
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
`src/AcDream.Core/Physics/BSPQuery.cs`,
@ -405,139 +211,6 @@ cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case.
---
## #35 — [DONE 2026-04-30] Retail debugger toolchain (cdb + PDB GUID matching)
**Status:** DONE
**Severity:** N/A (infrastructure)
**Filed + closed:** 2026-04-30
**Component:** tooling / research
**Description:** When the question is "what does retail actually DO at
runtime?" — wedges, animation flicker, geometry-specific bugs where the
decomp is correct but the visible behavior is mysterious — there was no
way to attach a debugger to a live retail acclient.exe and trace it.
This issue tracks the toolchain that closed that gap.
**What shipped:**
- **`tools/pdb-extract/check_exe_pdb.py`** — reads any PE's CodeView entry
and reports `MATCH` / `MISMATCH (expected GUID = …)` against our
`refs/acclient.pdb`. Always run before attaching cdb.
- **`tools/pdb-extract/dump_pdb_info.py`** — dumps a PDB's expected
build timestamp + GUID + age. Used to figure out which acclient.exe
build pairs with our PDB (answer: v11.4186, Sept 2013 EoR).
- **CLAUDE.md "Retail debugger toolchain" section** — full workflow:
cdb path, sample `.cdb` script, PowerShell wrapper pattern, watchouts
(PDB name conventions, `;` parsing, kill-target-on-detach behavior,
high-hit-rate lag).
- **Step `-1` added to the development workflow** — "ATTACH cdb TO
RETAIL (when behavior is the question, not code)". Tells future
sessions: when guessing has failed twice in a row, don't keep guessing.
**Discoveries this toolchain enabled (closed in same session):**
- Retail integrates physics at 30Hz (`UpdatePhysicsInternal/update_object`
ratio = 0.61). Drove the L.5 fix in PlayerMovementController.
- `OBJECTINFO::kill_velocity` rarely fires in normal play (gated on
last_known_contact_plane_valid). Our acdream port now matches.
- Retail does NOT wedge on the steep-roof scenario. Confirmed our L.4
slide-tangent deviation in Path 6 is necessary until the retail
step_up_slide / cliff_slide chain audit lands.
**Files:** `tools/pdb-extract/check_exe_pdb.py`,
`tools/pdb-extract/dump_pdb_info.py`, `CLAUDE.md`,
`memory/project_retail_debugger.md`.
**Acceptance:** Future sessions can attach cdb to a live retail client
in under 5 minutes by following the CLAUDE.md workflow.
---
## #36 — Sky-PES dispatch port (consolidates #2 / #28 / #29 visual gaps)
**Status:** OPEN
**Severity:** MEDIUM (aesthetic feature-parity, but addresses a cluster of bugs)
**Filed:** 2026-04-30
**Component:** sky / weather / particles
**Description:** Three open sky bugs (#2 lightning, #28 aurora, #29 cloud
density) all trace back to the same missing infrastructure: retail's
sky-PES (Particle Effect Script) dispatch chain. We have it now from a
2026-04-30 cdb live trace.
**What retail does (live trace evidence):**
```
Trace over 24,576 GameSky::Draw frames:
GameSky::Draw = 24,576 (60 Hz render rate)
GameSky::UseTime = 12,288 (30 Hz — half rate, MinQuantum)
GameSky::CreateDeletePhysicsObjects = 12,288 (also 30 Hz)
CPhysicsObj::CallPES = 372 (~150/min average)
CallPESHook::Execute = 372 (1:1 with CallPES)
CreateParticleHook::Execute = 62 (15 at cell load + 47 burst at transition)
CPhysicsObj::create_particle_emitter = 62 (matches CreateParticleHook)
```
**Three findings:**
1. Retail has **persistent particle emitters** on celestial / sky objects.
Created at cell load (15 initial) and dynamically as conditions change
(the trace caught a +47 burst on a region/weather/time transition).
2. The PES script-hook system (`CallPESHook::Execute`
`CPhysicsObj::CallPES`) drives those emitters periodically, ~150
times per minute on average.
3. Earlier research said "GameSky doesn't read pes_id" — correct in
scope, but missed that the dispatch chain runs through the script-
hook system, not from inside GameSky directly. Cell/region/weather
handlers schedule PES script hooks; those hooks call into CallPES.
**Decomp anchors:**
- `CallPESHook::Execute` @ `0x00526e20` — script-hook action that fires CallPES
- `CreateParticleHook::Execute` @ `0x00526ec0` — particle-creation hook
- `CPhysicsObj::CallPES` @ `0x00511af0`
- `CPhysicsObj::create_particle_emitter` @ `0x0050f360`
- `GameSky::CreateDeletePhysicsObjects` @ `0x005073c0`
- `LongNIHash<ParticleEmitter>` instance — emitter registry
- `CelestialPosition.pes_id` @ struct offset +0x004 — populated by
`SkyDesc::GetSky` but consumed downstream of `GameSky` (via the
hook system, not GameSky itself)
**Implementation outline:**
1. Decomp dive: read `CallPESHook::Execute`, `CreateParticleHook::Execute`,
`CPhysicsObj::CallPES`, and `GameSky::CreateDeletePhysicsObjects`
(and any cell/region weather handlers that spawn the dynamic 47).
2. Identify what triggers `CreateParticleHook` for sky objects — is it
inside `CreateDeletePhysicsObjects`, the region/weather change handler,
or somewhere else?
3. Port the persistent-emitter creation path: when a cell loads or
weather/time changes, instantiate the appropriate ParticleEmitters
on celestial objects.
4. Port the PES timeline driver — periodic dispatch from a script
timeline into our equivalent `CallPES`.
5. Port the actual PES script execution (rate of emission, particle
parameters, etc.) into our particle system.
6. Live verify with cdb during specific weather windows: aurora at dusk
on Rainy DayGroup, lightning during storm.
**Files** (likely):
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — emitter wiring
- `src/AcDream.Core/World/SkyDescLoader.cs` — already parses pes_id
- `src/AcDream.Core/Particles/*` — particle system foundation
- `src/AcDream.App/Rendering/ParticleRenderer.cs` — visual layer
**Live-trace verification plan (next cdb session):** Reattach to retail
during a specific aurora moment, log `this` pointer + `pes_id` arg on
every `CallPES` invocation, log the GfxObj being attached on every
`create_particle_emitter`. That tells us EXACTLY which celestial
objects retail PES-drives and with which IDs.
**Acceptance:** During the same in-game time/weather where retail shows
aurora-style light play (Rainy DayGroup, dusk/dawn windows), acdream
shows comparable colored sky effects. Cloud sheets look as dense /
purple as retail. Lightning flashes appear during storm windows.
**Closes-when-done:** #28, #29, partially #2 (lightning may need
additional flash-shader work).
---
## #33 — Live entity collision shape collapses to one cylinder
**Status:** OPEN
@ -592,8 +265,6 @@ one live creature case no longer use the single-cylinder fallback.
**Acceptance:** During a Rainy DayGroup's storm window, visible flashes appear in the sky at the dat-scripted moments, the fragment-shader flash bump briefly brightens the scene, and (later, once thunder audio is wired) a thunder clap plays with a short propagation delay.
**See also #36** (Sky-PES dispatch port) — the lightning visuals likely route through the same PES-hook chain that drives aurora and cloud-density. Most of #2's storm-flash visuals will be unblocked by the #36 port.
---
## #3 — Client clock drifts from retail after ~10 minutes (periodic TimeSync missing)
@ -717,8 +388,6 @@ acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetup
**Acceptance:** When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time.
**See #36 (filed 2026-04-30)** — a live cdb trace confirmed retail's aurora rendering uses the script-hook PES dispatch chain (`CallPESHook::Execute``CPhysicsObj::CallPES`) on persistent particle emitters, with a cell-load population (15 initial emitters) plus dynamic spawning on region/weather/time transitions (caught a +47 burst). Implementation work consolidated under #36.
---
## #29 — Cloud surface 0x08000023 still appears thinner than retail despite blend-mode + Setup fixes
@ -746,8 +415,6 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
**Acceptance:** Cloud sheets look as dense/purple as retail in dual-client side-by-side. May require #28 (PES) to land first.
**See #36 (filed 2026-04-30)** — confirmed via live cdb trace: retail's cloud density comes from the same PES-driven particle-emitter chain as aurora. Implementation consolidated there.
---
---

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning.
**Status:** Living document. Updated 2026-04-29 for Phase L.2 movement/collision conformance planning.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -403,101 +403,6 @@ diagnostic scaffolding, not yet the final collision system.
---
### Phase M — Network Stack Conformance
**Status:** PLANNED.
**Goal:** replace the current happy-path `WorldSession` networking shape with a
proper AC client network stack that reaches functional parity with
`references/holtburger/` while preserving acdream's stricter packet checksum
verification and live ACE compatibility. This phase owns packet reliability,
ordered delivery, retransmission, ACK piggybacking, echo/keepalive, typed
message/action routing, diagnostics, and the migration of low-level network
responsibilities out of the render tick.
**Why now:** The Phase 4/A.3 stack is good enough for local ACE smoke testing:
login, ISAAC, checksums, per-packet ACKs, fragments, movement, chat, combat,
and object updates all work. It is not yet a complete client network runtime.
Compared with holtburger it lacks ordered S2C delivery, retransmit request
handling, outbound packet caching/retransmission, ACK piggybacking,
EchoRequest/EchoResponse handling, runtime ping/timeout policy, and a typed
protocol/action layer. These gaps will become expensive as movement, dungeons,
inventory, combat, and plugins depend on stable packet semantics.
**Plan of record:** create
`docs/superpowers/specs/2026-05-02-network-stack-conformance.md` before
implementation starts. Treat holtburger as the client-behavior oracle for this
phase; cross-check wire details against named retail, ACE, Chorizite, and AC2D
before porting.
**Sub-lanes:**
- **M.1 — Audit & parity map.** Produce a source-by-source comparison of
acdream `AcDream.Core.Net` and holtburger `holtburger-session`,
`holtburger-protocol`, and `holtburger-core` networking code. Inventory each
packet flag, optional header, session transition, control packet, fragment
path, game message, and game action. Mark each as `parity`, `partial`,
`missing`, or `intentional divergence`.
- **M.2 — Layer extraction.** Split the low-level stack under `WorldSession`
into testable components: `INetTransport`, `PacketCodec`,
`ReliablePacketSession`, `FragmentSession`, `GameMessageSession`, and the
high-level `WorldSession` behavior layer. Preserve existing public events and
live-client call sites during the migration.
- **M.3 — Reliability core.** Port holtburger-style sequence tracking:
last-delivered server sequence, duplicate/old-packet suppression,
out-of-order buffering, missing-sequence detection, throttled
`RequestRetransmit`, outbound packet cache, server-requested C2S retransmit,
`RejectRetransmit` handling, and retransmission checksum recomputation.
- **M.4 — ACK and control-packet policy.** Replace standalone-only ACKs with
queued ACK state, ACK piggybacking on normal outbound packets, standalone ACK
flush when idle, and clean handling for ACK-only packets. Add EchoRequest /
EchoResponse and idle ping/timeout behavior matching holtburger unless named
retail proves a different cadence.
- **M.5 — Fragment and payload completeness.** Keep inbound multi-fragment
assembly, add TTL/eviction diagnostics for orphaned partials, implement
outbound multi-fragment splitting when payloads exceed the current single
fragment limit, and verify fragment id/sequence/queue behavior against
holtburger, ACE, and retail evidence.
- **M.6 — Typed protocol surface.** Introduce typed `GameMessage` and
`GameAction` routing modeled on holtburger. Migrate current builders and
parsers first: login complete, DDD, movement, chat/TurbineChat, combat,
spellcast, item/appraise, object/update/motion/position, and teleport.
Unknown opcodes must remain observable and non-fatal.
- **M.7 — Runtime loop and diagnostics.** Move decode/order/reassembly/control
handling out of the render tick into a dedicated network runtime that emits
clean session events through channels. Add byte counters, packet trace hooks,
optional capture/replay fixtures, idle/disconnect state, and dev-panel
diagnostics suitable for packet debugging.
- **M.8 — Conformance tests and live validation.** Add deterministic unit tests
for checksum, ISAAC key consumption, optional headers, ordering, retransmit,
ACK piggybacking, echo, fragments, typed actions, and login flow. Add
replay/capture tests from holtburger-style fixtures where possible. Finish
with `dotnet build`, `dotnet test`, local ACE login, chat, movement, combat
action smoke test, reconnect test, and user visual sign-off where networking
affects the running client.
**Non-goals:**
- Do not reimplement ACE server behavior.
- Do not replace acdream's stricter inbound checksum verification unless named
retail proves it is wrong.
- Do not rewrite renderer/gameplay systems as part of this phase; migrate
network call sites through compatibility adapters first.
- Do not remove unknown-message visibility. Plugins and devtools need packet
trace surfaces even when parsers are incomplete.
**Acceptance:**
- acdream has a layered, testable network stack rather than one monolithic
`WorldSession`.
- Every holtburger session capability has an acdream equivalent, an explicit
test, or a documented intentional divergence with retail/ACE evidence.
- Packet ordering, retransmit, outbound cache, ACK piggybacking, echo/keepalive,
fragment assembly/splitting, and typed message/action routing are covered by
tests.
- Live ACE loop succeeds: login, enter world, movement, chat, combat action,
teleport/reconnect, and clean logout.
- `dotnet build` and `dotnet test` are green for every implementation slice.
---
### Phase J — Long-tail (deferred / low-priority)
Not detailed here; each gets its own brainstorm when it becomes relevant.
@ -581,12 +486,6 @@ port in any phase — no separate listing here.
| Can't walk past the loaded 3×3 window | **A.1 FIXED** ✓ (5×5 default, `ACDREAM_STREAM_RADIUS` to tune) |
| Frame hitch crossing landblock boundary | **Phase A.3** (synchronous loader for now; async returns when DatCollection is thread-safe) |
| Walking around doesn't move me on the server | **Phase B.2/B.3 FIXED** ✓ for coarse server movement; fine retail collision parity is **Phase L.2** |
| Packet loss / out-of-order UDP causes stale or missing world updates | **Phase M.3** |
| Server asks for retransmit and client doesn't resend cached packets | **Phase M.3** |
| ACKs are standalone-only instead of piggybacked like a full client | **Phase M.4** |
| Echo / idle keepalive / reconnect behavior is incomplete | **Phase M.4 + M.7** |
| Large outbound game messages exceed the current single-fragment path | **Phase M.5** |
| Network protocol coverage is spread across ad-hoc builders/parsers | **Phase M.6** |
| Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** |
| Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** |
| Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** |

View file

@ -122,11 +122,8 @@ Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels
acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and
BSP step-down/find-walkable now preserve walkable polygon vertices; failed
step-down edge cases perform the retail back-probe before
`SPHEREPATH::precipice_slide`; precipice slide results now re-enter the
`TransitionalInsert` retry loop so tangent edge motion is preserved instead of
being reverted by outer validation. Remaining L.2c work is live visual
confirmation at real building/roof edges, real-DAT building-edge fixtures,
fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge
fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects

View file

@ -1,187 +0,0 @@
# Plan — Sky-PES dispatch port (Issue #36)
**Filed:** 2026-04-30 from a live cdb trace of retail acclient.exe.
**Owner:** next session.
**Closes:** #28 (aurora), #29 (cloud density), partially #2 (lightning).
## What we know (from the live trace, 24,576 GameSky::Draw frames)
Retail's sky-PES dispatch chain runs as follows. All counts are from
the cdb trace summarized in `memory/project_retail_debugger.md`:
```
GameSky::Draw = 24,576 (60Hz render rate)
GameSky::UseTime = 12,288 (30Hz, MinQuantum gate)
GameSky::CreateDeletePhysicsObjects = 12,288 (30Hz)
CPhysicsObj::CallPES = 372 (~150/min)
CallPESHook::Execute = 372 (1:1 with CallPES)
CreateParticleHook::Execute = 62 (15 initial + 47 burst)
CPhysicsObj::create_particle_emitter = 62 (matches CreateParticleHook)
```
Three concrete findings:
1. **Persistent particle emitters on celestial / sky objects.** 15 are
created at cell load. More are spawned dynamically on region /
weather / time-of-day transitions (the trace caught a +47 burst on
one such transition).
2. **Periodic PES dispatch drives existing emitters.**
`CallPESHook::Execute` runs script-scheduled actions which call
`CPhysicsObj::CallPES` 1:1. ~150 dispatches/min on average.
3. **Earlier research said "GameSky doesn't read pes_id" — true but
misleading.** GameSky doesn't read it directly; the script-hook
system does. `CelestialPosition.pes_id` (struct offset +0x004) is
populated by `SkyDesc::GetSky` and consumed downstream by
`CallPESHook` / `CreateParticleHook` invocations scheduled from
region/weather handlers.
## Decomp anchors
All addresses verified live against `refs/acclient.pdb`:
| Function | Address | Role |
|---|---|---|
| `CallPESHook::Execute` | `0x00526e20` | Script-hook action that fires CallPES |
| `CreateParticleHook::Execute` | `0x00526ec0` | Particle-creation hook |
| `CPhysicsObj::CallPES` | `0x00511af0` | Top-level PES dispatch |
| `CPhysicsObj::CallPESInternal` | `0x00511ac0` | Inner dispatch (never fires alone in trace — likely inlined) |
| `CPhysicsObj::create_particle_emitter` | `0x0050f360` | Creates a new emitter |
| `CPhysicsObj::create_blocking_particle_emitter` | `0x0050f3b0` | Creates a blocking emitter |
| `CPhysicsObj::stop_particle_emitter` | `0x0050f420` | |
| `CPhysicsObj::destroy_particle_emitter` | `0x0050f400` | |
| `CPhysicsObj::ShouldDrawParticles` | `0x0050fe60` | |
| `CPhysicsObj::makeParticleObject` | `0x00512640` | |
| `CPartArray::CreateParticle` | `0x005194f0` | |
| `CSetup::makeParticleSetup` | `0x005201f0` | |
| `LongNIHash<ParticleEmitter>::add` | `0x005198c0` | Emitter registry insertion |
| `GameSky::CreateDeletePhysicsObjects` | `0x005073c0` | Already partially decoded; entry point for sky-object lifecycle |
| `GameSky::UseTime` | `0x005075b0` | Per-frame sky update (30Hz) |
| `GameSky::Draw` | `0x00506ff0` | Sky render (60Hz) |
| `SkyDesc::GetSky` | `0x00501ec0` | Populates `CelestialPosition.pes_id` |
`CelestialPosition` struct layout:
```
+0x000 gfx_id : 4 bytes
+0x004 pes_id : 4 bytes ← THIS is what gets PES-driven
+0x008 heading : float
+0x00c rotation : float
+0x010 tex_velocity : Vector3
+0x01c transparent : float
+0x020 luminosity : float
+0x024 max_bright : float
+0x028 properties : 4 bytes
```
## Phase plan
### Phase M.1 — Decomp dive (no code changes)
Read these functions in order. Save findings to
`docs/research/2026-04-30-sky-pes-pseudocode.md`:
1. **`CallPESHook::Execute`** at `0x00526e20`. What state does it
read? What does it call? Answer: probably "look up the target
CPhysicsObj by some ID, call CallPES on it." Confirm.
2. **`CPhysicsObj::CallPES`** at `0x00511af0`. What does it do?
Probably: "look up or load the PES file referenced by `pes_id`,
start running its script timeline." Find the script-evaluation
loop and where it dispatches `CallPESHook` events.
3. **`CreateParticleHook::Execute`** at `0x00526ec0`. When this hook
fires, what does it create? What CPhysicsObj does it attach the
emitter to? Probably calls `CPhysicsObj::create_particle_emitter`.
4. **`CPhysicsObj::create_particle_emitter`** at `0x0050f360`. What
does it instantiate? What goes into the `LongNIHash<ParticleEmitter>`?
5. **`GameSky::CreateDeletePhysicsObjects`** at `0x005073c0`. The
prior research said this doesn't read pes_id. Confirm — but ALSO
check: does it set up the script-hook timeline somewhere? Or does
that happen in a separate caller?
6. **The dynamic-emitter-spawn trigger.** The trace caught a +47
burst — find what fires CreateParticleHook on region / weather /
time-of-day transitions. Likely candidates:
- `LScape` weather handler
- `CDayCycle` / `CWorldFog` region handler
- Cell-load or cell-cross handler
### Phase M.2 — Verify with detailed cdb trace (one focused session)
After M.1 reveals the wiring, attach cdb to retail and capture:
- `this` pointer + `pes_id` arg on every `CPhysicsObj::CallPES`
- GfxObj being attached on every `create_particle_emitter`
- Stack walk at `CallPESHook::Execute` to confirm the caller chain
- Watch for the dynamic +N burst — what global state changed at
that frame?
The data should match the M.1 decomp predictions. If it diverges,
the decomp interpretation needs another pass.
### Phase M.3 — Implementation (acdream port)
1. **Persistent emitter creation at cell load.** When a sky-bearing
landblock loads, walk SkyDesc / CelestialPosition entries; for
each entry with non-zero `pes_id`, instantiate a ParticleEmitter
on the corresponding sky CPhysicsObj.
2. **Dynamic emitter spawn on transitions.** Hook into our region /
weather / day-cycle change events; replicate retail's dispatch.
3. **PES script-timeline driver.** Port the scheduler that fires
`CallPESHook` events at script-defined moments. May reuse
`references/holtburger` if there's a Rust port. If not, port from
decomp directly.
4. **Particle-system rendering wire-up.** acdream already has a
particle system (R3 era). Verify it can accept emitter spawns
from this path. If so, just wire. If not, identify the gap.
5. **Surface 0x08000023 / cloud GfxObjs.** Once dynamic emitters spawn,
#29's "clouds too thin" should resolve naturally.
### Phase M.4 — Live verification
1. Retail + acdream side-by-side. Aurora moment (Rainy DayGroup,
dusk/dawn). Compare visual.
2. Cloudy moment — clouds should look as dense as retail.
3. Storm moment — lightning flashes (covers part of #2).
4. Run another cdb trace; counts should match retail's counts within
~10%.
## Acceptance
- Aurora visible in acdream at the same in-game moments retail shows it.
- Cloud sheets look as dense / purple as retail.
- Storm flash visible during Rainy storm windows (part of #2).
- New cdb trace shows similar PES dispatch rate (~150/min) and similar
emitter spawn pattern (initial population + transition bursts).
- Closes #28, #29. Updates #2 with the storm-flash story.
## What this doesn't fix
- **#4 horizon-glow** is a separate issue (fog parameters, not particles).
Tackle that in a different session — different code path, different
cdb trace.
- **Lightning timing / thunder** (the audio half of #2) is separate;
needs the audio system wired.
## How to start
The next session, having read CLAUDE.md (auto-loaded) and
`memory/project_retail_debugger.md` (auto-loaded), should:
1. Read this plan top to bottom (~5 minutes).
2. Begin Phase M.1 decomp dive — no code yet, just understand the
wiring. Save findings to a research doc.
3. After M.1 lands, decide whether M.2 (verify with cdb) is needed
before M.3 (implement). Often the decomp alone is enough; M.2 is
for resolving ambiguity.
The cdb tooling is ready (CLAUDE.md "Retail debugger toolchain").
The user can launch retail with `C:\Turbine\Asheron's Call\acclient.exe`
on demand.

View file

@ -1,233 +0,0 @@
# Retail motion outbound trace — findings
**Date:** 2026-05-01
**Tool:** cdb 10.0.28000.1839 attached non-invasively to live retail
acclient.exe v11.4186 (Sept 2013 EoR build, GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`).
**Symbols:** `refs/acclient.pdb` — 18,366 named functions, 5,371 named
struct/class types.
**Server:** local ACE on `127.0.0.1:9000`. Character: `+Acdream`,
spawned in Holtburg.
## TL;DR
- **WASD movement does NOT go through `ACCmdInterp::SendDoMovementEvent`.**
Across two captures totalling ~5 minutes of running, jumping, and
turning, that breakpoint fired **zero** times. `SendDoMovementEvent` is
apparently slash-command-only (`/run`, `/walk`, `/sneak`, etc.). The
per-frame movement send path is `CommandInterpreter::SendMovementEvent`
(which we couldn't trace stably — see "Open" below).
- **Jump goes through `CM_Movement::Event_Jump`.** It fires
**per-frame while the spacebar is held** (charge phase), not just once
per jump. 44 hits in ~30 sec of jumping. Same `JumpPack` stack pointer
every time — function probably packs every frame but conditionally
sends.
- **AutoPos heartbeat fires often during sustained motion** (~5 Hz at
rest after gating, much higher under motion). It's gated by
`transient_state & 1 && transient_state & 2 && Position::IsValid` so
**does not fire when the player is standing still**.
- **`set_heading` is for player rotation only, not camera mouse-look.**
Camera pan does not fire it; turn keys / strafe / character autoface do.
- **`set_state`** XOR mask shows which physics-state bits change on each
call. Distinct values observed: 11 different new-state bitmasks
across two captures (see "set_state bitmask atlas" below).
## What we ran
| Run | Duration | Activity | Hits |
|-----|----------|----------|------|
| v1 | ~60 sec | Run forward + turn | 127 |
| v2 | ~3 min | Run + jump + turn (holding W, repeated jumps) | 247 |
Both with the same six-breakpoint set:
`SendDoMovementEvent`, `SendStopMovementEvent`, `set_state`,
`set_heading`, `Event_Jump`, `Event_AutonomousPosition`. Logs at
[motion_trace_v1_walk_and_turn_2026-05-01.log](../../../.claude/worktrees/jovial-blackburn-773942/motion_trace_v1_walk_and_turn_2026-05-01.log)
and [motion_trace_v2_run_jump_turn_2026-05-01.log](../../../.claude/worktrees/jovial-blackburn-773942/motion_trace_v2_run_jump_turn_2026-05-01.log).
A v3 attempt added `CommandInterpreter::SendMovementEvent` (the
per-frame MoveToState gateway). The cdb attach + breakpoint setup with
that 7th breakpoint added enough overhead that retail's network thread
starved; ACE timed out the session and retail crashed within seconds of
attach. That bp is too high-frequency to instrument with a `printf` in
the action — needs a separate, minimal-overhead trace (counter only,
no print).
## Hit distribution (v2)
| BP | Hits | Notes |
|----|------|-------|
| `set_state` | 159 | All 11 distinct new-masks; clusters around motion transitions |
| `Event_Jump` | 44 | Bursts during sustained spacebar-hold |
| `Event_AutonomousPosition` (printed) | 38 | Throttle 1/10 → ~370 actual hits |
| `set_heading` (printed) | 6 | Throttle 1/10 → ~60 actual; only fires on player rotate |
| `SendDoMovementEvent` | 0 | **Confirmed unused for keyboard motion** |
| `SendStopMovementEvent` | 0 | **Confirmed unused** |
## set_state bitmask atlas
Captured `new` masks (current state was `0x00400c08` baseline, sometimes
`0x00410c08`). Distinct values across both runs:
| `new` | bits set | Likely meaning (cross-checked vs `PHYSICS_STATE` enum candidates) |
|-------|----------|--------------------------------------------------------------------|
| `0x00000408` | 3, 10 | Base "in world, animating" |
| `0x00000410` | 4, 10 | Walk / sidestep variant |
| `0x00000414` | 2, 4, 10 | Walking + something |
| `0x00000418` | 3, 4, 10 | Walking + alt |
| `0x00000c0c` | 2, 3, 10, 11 | Multi-state combo |
| `0x00000c14` | 2, 4, 10, 11 | Combo near jump |
| `0x00010008` | 3, 16 | Bit 16 likely "scripted/cinematic"? |
| `0x00020414` | 2, 4, 10, 17 | Bit 17 likely something high |
| `0x00200418` | 3, 4, 10, 21 | Bit 21 likely "frozen/inert" |
| `0x00600418` | 3, 4, 10, 21, 22 | Bits 21+22 — common during motion |
| `0x0060041c` | 2, 3, 4, 10, 21, 22 | Same + bit 2 |
**Action item:** decode the `PhysicsState` enum bits from
`docs/research/named-retail/acclient.h` and label this table
authoritatively. The `cur` value `0x00400c08` (bits 3, 10, 11, 22)
should be the "neutral, in-world, alive" base.
## set_heading angle samples
Angles captured (4-byte float bit pattern, decoded):
| Hex | Float (degrees) |
|-----|-----------------|
| `0x00000000` | 0.0 (north) |
| `0x41c8af72` | 25.09 |
| `0x43070000` | 135.0 |
| `0x43083c22` | 136.23 |
| `0x430d7596` | 141.46 |
| `0x43340000` | 180.0 |
| `0x433a1741` | 186.09 |
| `0x43a9d6c7` | 339.68 |
Confirms heading is **degrees, float, 0360 wrap**. Matches the
acdream-side encoding in the outbound builders.
## Event_Jump pattern
44 hits in v2 — way more than user keypresses. Pattern:
```
[70..92] burst of 9 hits (one charged jump session)
[97..138] burst of 11 hits (another charged jump)
[251..408] continuous hits (multiple chained jumps)
```
Same `JumpPack` pointer (`001af5f8`) on every hit — stack-allocated
local in the caller. Consistent with `Event_Jump` being called
**per-frame while the jump button is held to charge**, with the
function itself deciding whether to actually send the pack on the wire.
We did not break inside the function so we don't have the send/no-send
ratio.
**Action item:** trace one jump in isolation — set a bp on
`Event_Jump` AND on the BSTREAM-write deeper in the function. Capture
how many of the per-frame Event_Jump calls actually emit a 0xF61B
JumpAction packet. acdream's [JumpAction.cs](../../../src/AcDream.Core.Net/Messages/JumpAction.cs)
sends one JumpAction per spacebar release — if retail sends one per
charge-frame, our outbound is wrong (or vice-versa).
## AutoPos heartbeat behaviour
`CM_Movement::Event_AutonomousPosition` is called from
`CommandInterpreter::SendPositionEvent` (006b4770), which is
**guarded** by:
```c
if (this->smartbox != 0 && this->player != 0 &&
(transient_state & 1) != 0 && (transient_state & 2) != 0 &&
Position::IsValid(&player->m_position))
```
`transient_state` bits 0 and 1 are required — this is why AutoPos
**doesn't fire when the player is fully at rest**. During sustained
motion it fires ~5 Hz at the throttle interval we saw (1 print every
10 hits = ~1 line/sec → ~50 hits in 5 sec).
**Action item:** acdream's outbound heartbeat in
[GameWindow.cs](../../../src/AcDream.App/Rendering/GameWindow.cs)
is a 200ms periodic pulse. Verify whether retail's cadence matches our
fixed 200ms or is event-driven (e.g. fires per-frame while moving and
not at all at rest). If event-driven, we may be sending stale
heartbeats while standing still that retail would suppress.
## Confirmed correct vs acdream-side
| Behaviour | Retail observed | acdream | Match |
|-----------|-----------------|---------|-------|
| Heading encoding | float degrees 0360 | float degrees 0360 in MoveToState | ✓ |
| Heartbeat at rest | does not send | sends every 200ms | **MISMATCH** — ours sends extras |
| Slash-command motion path | `SendDoMovementEvent` (unused for WASD) | n/a (acdream doesn't send slash motions) | ✓ |
| Jump per-frame charge | `Event_Jump` fires every frame while spacebar held | acdream sends 1 JumpAction on release | **POSSIBLE MISMATCH** — needs deeper trace |
## Open / what we still don't have
1. **The actual 0xF61C MoveToState send path** — retail's per-frame
movement message. Our breakpoint
on `CommandInterpreter::SendMovementEvent` (0x006B4680) was the
right entry but adding it to the trace caused retail to crash
(cumulative bp-action overhead). Needs a SEPARATE focused trace with
just that bp + a counter (no `printf`).
2. **The exact wire bytes of MoveToState / AutonomousPosition / Jump.**
Was planned for the v3 wire-trace; not run. Would require
`RawMotionState::Pack` and `AutonomousPositionPack::Pack` breakpoints
with `dt` struct dump + `db` byte dump. Same overhead concerns; do
one bp at a time.
3. **Sequence-counter behaviour.** Our trace captured nothing about
how retail bumps the four `Instance/ServerControl/Teleport/ForcePosition`
sequence counters across messages. Needs taps inside
`MoveToStatePack` / `AutonomousPositionPack` constructors.
4. **The `PhysicsState` enum decode.** Names map for the 0x408 / 0x418
/ 0x600418 / 0x10008 etc. bitmasks lives in
`docs/research/named-retail/acclient.h`. Pull the `PHYSICS_STATE`
enum and label this trace's set_state column authoritatively.
5. **DoMov / StopMov context.** Confirmed unused for WASD, but
`SendDoMovementEvent(0x85000001, 0x3f800000, 0)` was found in the
pseudo-C as a real call site — find what triggers it (slash command?
autorun toggle?) and document so our IS_SLASH_COMMAND test paths know
what to expect.
## Toolchain notes
- **`qd` is not allowed inside breakpoint command actions** in cdb.
Empirically silently ignored (no parse error). Quoting the docs:
"you cannot use commands that wait for input or end the debugger
session in breakpoint command lists." `qd`, `q`, `qq` all fall under
this. **Use `.detach` (a meta-command) instead** when you need a
bp-driven detach.
- **`cdb -pd` does NOT survive `Stop-Process -Force`.** `-pd` makes the
*graceful* exit not terminate the debuggee. `TerminateProcess` (which
is what `Stop-Process -Force` does) bypasses cleanup and the OS
treats debugger termination as debuggee termination. Need either
`qd`/`.detach` from inside, or send `CTRL_BREAK_EVENT` to cdb (via
P/Invoke) and feed `qd` to its stdin.
- **Per-frame breakpoints with `printf` actions are too expensive.**
Adding `CommandInterpreter::SendMovementEvent` (called every motion
tick, ~30 Hz) with a `printf` action lagged retail enough that the
ACE session timed out within a few seconds. **For per-frame
breakpoints, use counter-only actions** (`r $tN = @$tN + 1; gc`) —
no `.printf`, no `poi` reads.
## Files
- [motion_discovery.cdb](../../../.claude/worktrees/jovial-blackburn-773942/motion_discovery.cdb)
— symbol resolution + prologue dump (one-shot, kept for reference)
- [motion_trace.cdb](../../../.claude/worktrees/jovial-blackburn-773942/motion_trace.cdb)
— current motion-trace script (v4, with `.detach`)
- [run_motion_trace.ps1](../../../.claude/worktrees/jovial-blackburn-773942/run_motion_trace.ps1)
— launcher with timer-based fallback
- v1 log: walk + turn, no jump
- v2 log: run + jump + turn
## Next session
1. Decode `PHYSICS_STATE` enum from `acclient.h`. 30 minutes.
2. Single-bp focused trace of `Event_Jump` with `db` byte dump on the
pack, to determine send-rate vs charge-frame-rate.
3. Single-bp focused trace of `CommandInterpreter::SendMovementEvent`
(counter only) to confirm per-frame fire and measure rate.
4. Wire-bytes trace using `RawMotionState::Pack` + post-`Pack` byte
dump for one each of: idle, run-forward, run+turn, jump.

View file

@ -1,267 +0,0 @@
# acdream vs retail motion outbound — gap analysis + fixes
Companion to [findings.md](findings.md). Compares acdream's current
outbound motion code to retail's observed behaviour AND to holtburger
(the most authoritative working AC client we have, Rust TUI).
## Summary table
| Behaviour | Retail (live trace) | Holtburger (Rust client) | acdream | Verdict |
|-----------|---------------------|--------------------------|---------|---------|
| MoveToState dispatch trigger | Per-frame in `CommandInterpreter::SendMovementEvent`, gated by `InqRawMotionState != 0` and a `last_sent_position_time` rate-limit (rate unknown) | On motion-intent change (`last_server_motion_intent != current`) | On motion-state change (`MotionStateChanged`) | acdream ≈ holtburger; retail probably more aggressive but rate-limited — **likely OK** |
| AutonomousPosition cadence | ~1 Hz observed in v2 (~380 hits over ~5 min of activity) | **1.0 sec** const (`AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL`) | **0.2 sec** (5 Hz) | **acdream is 5× too aggressive** |
| AutoPos at rest | Likely yes (gated by `transient_state & 1 && transient_state & 2`, not by motion) | Yes (gated by `has_autonomous_position_sync_target`, not motion) | **No** — acdream only ticks heartbeat while `isMoving` | **acdream MISSING at-rest heartbeat** |
| MoveToState content (heading) | float degrees 0360 | float degrees 0360 | float degrees 0360 | ✓ match |
| HoldKey enum | Invalid=0 / None=1 / Run=2 | Same | Same (`HoldKeyNone=1`, `HoldKeyRun=2`) | ✓ match |
| Slash-command motion path | `SendDoMovementEvent` (zero hits in WASD trace — confirmed unused) | n/a | n/a | ✓ both correctly skip |
| Jump dispatch | `Event_Jump` fires per-frame while spacebar held; same `JumpPack` ptr suggests function-internal gating | (not yet checked) | Single `JumpAction` on spacebar release | **Unknown — needs deeper retail trace** |
## Concrete fixes
### Fix #1 — Heartbeat interval 200 ms → 1000 ms
**Confidence:** high. Holtburger's constant is explicit and named; our retail
trace ratio (~380 hits / ~5 min) matches 1 Hz; ACE has no expectation
of a 200 ms cadence anywhere I can find.
**Where:** [PlayerMovementController.cs:172](src/AcDream.App/Input/PlayerMovementController.cs:172)
```csharp
public const float HeartbeatInterval = 0.2f; // 200ms
```
Change to:
```csharp
// Holtburger constant + retail trace 2026-05-01.
// Ours used to be 0.2s — far too aggressive; observers may have
// interpreted the dense pulse stream as jitter.
public const float HeartbeatInterval = 1.0f; // 1000ms
```
**Risk:** dropping the heartbeat rate by 5× means observers' dead-reckoning
extrapolates over a longer interval between confirmation pulses. If our
position drift is bounded (no extreme client-side prediction), this is
fine — that's exactly what retail/holtburger do. Expect slightly less
"crisp" remote-observer view of the player's *idle* position, no
practical effect during motion (MoveToState fires on every state change,
which is much more frequent than 1 Hz under WASD activity).
### Fix #2 — Send AutonomousPosition at rest, not just while moving
**Confidence:** medium-high. Holtburger explicitly schedules the heartbeat
as long as a sync target exists, regardless of whether the player is
moving. Our retail trace was inconclusive at rest (cdb's attach
overhead made the "stand still" scenario unreliable) but the named
`SendPositionEvent` gate is on `transient_state` (in-world, alive,
valid pose), not on `is_moving`.
**Where:** [PlayerMovementController.cs:765-779](src/AcDream.App/Input/PlayerMovementController.cs:765)
```csharp
bool isMoving = outForwardCmd is not null
|| outSidestepCmd is not null
|| outTurnCmd is not null;
if (isMoving)
{
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
}
else
{
_heartbeatAccum = 0f;
HeartbeatDue = false;
}
```
Change to (drop the `isMoving` gate, replace with a "valid pose" gate
that mirrors retail's `transient_state & 1 && transient_state & 2 &&
Position::IsValid`):
```csharp
// Heartbeat is a SYNC PULSE, not a motion broadcast. It fires whenever
// the player is in a valid in-world pose — at rest OR moving — so the
// server's last-known-position stays fresh. Holtburger uses 1 Hz
// regardless of motion; retail's SendPositionEvent gate is
// transient_state-based, not motion-based.
if (PlayerState == PlayerState.InWorld && Position.HasValidPose())
{
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
}
else
{
_heartbeatAccum = 0f;
HeartbeatDue = false;
}
```
(`Position.HasValidPose()` is illustrative — use whatever validity
predicate corresponds to "logged in, not portaling, not dead". The
`PlayerState.PortalSpace` early-return at the top of `Update` already
handles portal travel; the InWorld check above covers the rest.)
**Risk:** the server gets ~1 extra packet per second while the player is
idle. Negligible bandwidth. Aligns with holtburger and (likely) retail.
Possible win: ACE may have been silently dropping the player's session
or marking them stale during long idle periods because we stopped
heart-beating.
### Fix #3 — Jump velocity must be sent in body-LOCAL space, not world space
**Confidence: very high.** This is THE bug behind "acdream jumps in
the wrong direction when observed from retail."
**Evidence chain:**
1. Retail's jump caller at `0x0056b1e7` does
```c
CPhysicsObj::get_local_physics_velocity(player, &var_70);
JumpPack::JumpPack(&var_64, extent, &var_70 /*velocity*/, ...);
CM_Movement::Event_Jump(&var_64);
```
It explicitly uses `get_LOCAL_physics_velocity`, not the world-space
accessor.
2. The retail header (`acclient.h:54020`) defines
`JumpPack::velocity` as `AC1Legacy::Vector3`, with no
"this is local space" comment — but the construction site decides
this. The server reverses on receive: server applies the player's
heading rotation to transform the body-space velocity back into
world space for broadcast.
3. `get_local_physics_velocity` body
(`acclient.exe:0x00512140`) does
`local = fl2gv * worldVelocity` where `fl2gv` is a 3×3 row-major
matrix whose rows are the player's local axes expressed in world
coordinates. That's the inverse of the heading rotation — exactly
the world→local transform.
4. acdream's
[JumpAction.cs:64](src/AcDream.App/Input/PlayerMovementController.cs:64)
declares `JumpVelocity` as
`// world-space launch velocity (sent in jump packet)`.
[JumpAction.cs:433](src/AcDream.App/Input/PlayerMovementController.cs:433)
captures `outJumpVelocity = _body.Velocity;``_body.Velocity` is
in world space (verified by the gravity integration code that
subtracts world-Z from it each frame).
5. **Result:** observers receive an unrotated world vector, the
server's broadcast pipeline rotates it AGAIN by the player's
heading at receive-time. Net effect: jump direction is rotated by
`yaw` worth of rotation in the wrong direction. Standing facing
north and jumping forward → observer sees the player jump
sideways. Standing facing east and jumping forward → observer sees
the player jump backward. Etc. **Exactly the symptom reported.**
**Where to fix:** [PlayerMovementController.cs:433](src/AcDream.App/Input/PlayerMovementController.cs:433)
right where `outJumpVelocity = _body.Velocity` is captured.
```csharp
// BEFORE:
outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it
```
```csharp
// AFTER:
// Retail sends jump velocity in BODY-LOCAL space (forward / right /
// up relative to the player's facing) — see CPhysicsObj::
// get_local_physics_velocity at 0x00512140 and the call site at
// 0x0056b1e7. The server applies the player's heading on receive to
// rotate body→world; if we send world-space, observers see the
// jump rotated by `yaw`. Convert via inverse-yaw rotation around Z.
var worldVel = _body.Velocity;
float cy = MathF.Cos(Yaw);
float sy = MathF.Sin(Yaw);
outJumpVelocity = new Vector3(
cy * worldVel.X + sy * worldVel.Y, // local-axis-0 component
-sy * worldVel.X + cy * worldVel.Y, // local-axis-1 component
worldVel.Z); // local-axis-2 (gravity-aligned)
```
**Caveat — verify the sign of `Yaw` and the AC local-axis convention
before merging.** AC's heading convention is 0° = north = +Y, growing
clockwise toward east = +X. The above formula assumes
`world = R(Yaw) * local` so `local = R(-Yaw) * world`. If acdream's
`Yaw` is signed the other way, flip the signs of the `sy` terms. A
two-test verification: jump while facing north (cy=1, sy=0) — the
rotation should be a no-op and `local == world`; jump while facing
east (cy=0, sy=1) — the world `(1, 0, 0)` should map to local
`(0, -1, 0)` (i.e., to the right of facing-north when standing facing
east means body-axis-1 negative).
**Confirm via cdb later (not blocking the fix):** trace a single
retail jump-forward, dump the JumpPack at `Event_Jump` entry via
`dt acclient!JumpPack poi(esp+4)`. The `velocity.x/y/z` fields will be
the canonical local-space values for "jump forward". Compare to
acdream's outbound after the fix — they should match within FP
rounding.
### Fix #4 — Event_Jump send-rate (research-only, low priority)
Retail's `Event_Jump` fires per-frame while the spacebar is held to
charge. Same `JumpPack` ptr (`001af5f8`) on every hit indicates a
stack-allocated local in the caller — function probably has internal
gating that decides send-vs-skip based on a state delta. acdream
sends ONE `JumpAction` per spacebar release. Until we trace inside
`Event_Jump` to count actual sends, leave acdream's behaviour alone.
Filed at low priority because the wrong-direction bug (Fix #3) is
the bigger issue.
## Things that look RIGHT
These don't need changes — written down so we don't second-guess them
later:
- **MoveToState send-on-state-change.** Holtburger does the same. The
motion-state delta detection in
[PlayerMovementController.cs:744-756](src/AcDream.App/Input/PlayerMovementController.cs:744)
(forward-cmd, sidestep, turn, speed, run-hold, local-anim-cmd) is the
right set of fields to compare.
- **WASD does not invoke `SendDoMovementEvent`.** That's a slash-command
path retail keeps for `/run`, `/walk`, etc. Our outbound correctly
skips it.
- **HoldKey encoding** (Invalid=0/None=1/Run=2). Matches retail and
holtburger.
- **Heading float-degrees encoding.** Matches retail (we decoded 8
distinct angles cleanly to 0360°).
- **Per-axis hold-key broadcast.** acdream's
[GameWindow.cs:4949-4952](src/AcDream.App/Rendering/GameWindow.cs:4949)
sends `axisHoldKey` for every active axis — matches holtburger's
`build_motion_state_raw_motion_state`.
## Test plan after fixes
For Fix #1 + Fix #2 together:
1. Build + run acdream, log in as `+Acdream`.
2. Open Wireshark on loopback `127.0.0.1`, filter on UDP port 9000.
3. Walk forward for 5 sec, stop for 5 sec, walk again for 5 sec, stop
for 5 sec, log out.
4. In Wireshark, count outbound 0xF753 (AutonomousPosition) packets
over the 20 sec window.
- **Pre-fix:** expect ~50 (5 Hz × 10 sec moving + 0 at rest).
- **Post-fix:** expect ~20 (1 Hz × 20 sec, both moving and at rest).
5. Have a retail observer in-world watching `+Acdream`. Stand still
for 30 sec without moving. Pre-fix: retail might mark us stale or
show our idle pose desyncing. Post-fix: should stay synced.
## Files to modify
Both fixes touch one file:
- [src/AcDream.App/Input/PlayerMovementController.cs](src/AcDream.App/Input/PlayerMovementController.cs)
- Line 172: `HeartbeatInterval` constant
- Lines 765779: `isMoving` gate around `_heartbeatAccum` update
No changes to message builders ([MoveToState.cs](src/AcDream.Core.Net/Messages/MoveToState.cs),
[AutonomousPosition.cs](src/AcDream.Core.Net/Messages/AutonomousPosition.cs),
[JumpAction.cs](src/AcDream.Core.Net/Messages/JumpAction.cs)) needed.
No changes to wire-level dispatch in
[GameWindow.cs](src/AcDream.App/Rendering/GameWindow.cs) needed.
## Tracking
File these as ISSUES.md entries when implementing:
- **#motion.heartbeat-interval** (Fix #1) — `HeartbeatInterval = 1.0f`. ~10 lines, plus test.
- **#motion.heartbeat-at-rest** (Fix #2) — drop the `isMoving` gate. ~15 lines, plus test that exercises the at-rest pulse.
- **#motion.jump-charge-rate** (Fix #3) — research-only until retail trace lands. Don't change code yet.

View file

@ -72,9 +72,3 @@ InputDispatcher / PlayerMovementController
step-down edge cases run the retail back-probe before precipice slide.
`cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full
`cell_bsp`, and real-DAT building portal conformance remain open L.2 work.
- 2026-04-30: Edge-slide retry-loop lesson. `SPHEREPATH::precipice_slide`
usually returns `Slid` after applying the tangent offset. That result must
be handled inside `TransitionalInsert` like wall slide (`continue` and
re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the
outer validator treats non-OK as a collision and restores `CurPos`, making
edges feel like hard stops even when the tangent was computed.

View file

@ -61,7 +61,7 @@ public readonly record struct MovementResult(
float LocalAnimationSpeed = 1f,
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
float? JumpExtent = null, // non-null when a jump was triggered this frame
Vector3? JumpVelocity = null); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast.
Vector3? JumpVelocity = null); // world-space launch velocity (sent in jump packet)
/// <summary>
/// Portal-space state for the player movement controller.
@ -168,41 +168,10 @@ public sealed class PlayerMovementController
private uint? _prevLocalAnimCmd;
// Heartbeat timer.
// Cadence is 1.0 sec to match holtburger's
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
// but wasteful and probably looked like jitter to observers.
private float _heartbeatAccum;
public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger
public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; }
// L.5 retail physics-tick gate (2026-04-30).
//
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
// MinQuantum (1/30s) sized integration steps, SKIPPING entirely when
// accumulated dt is below MinQuantum. The retail debugger trace
// confirmed this: UpdatePhysicsInternal fires only ~61% as often as
// update_object — i.e., retail's effective physics tick rate is 30Hz
// even when the renderer runs at 60+Hz.
//
// Without this gate our acdream integrates at the full render rate
// (60+Hz), which compresses bounce-energy / gravity-tangent
// accumulation into half the time. Per-frame V grows ~2x faster than
// retail's. On a steep-slope tangent that produces the wedge: V grows
// tangent + huge while position reverts each frame, body locks in
// place. Retail's slower integration cadence (and larger per-tick
// position deltas) lets the body geometrically escape the tangent.
//
// Source: retail debugger trace 2026-04-30
// update_object = 40,960 calls
// UpdatePhysicsInternal = 25,087 calls (61%)
// ratio implies 39% of frames return early via the MinQuantum gate.
//
// ACE: PhysicsObj.UpdateObject (Physics.cs).
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
private float _physicsAccum;
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
@ -433,106 +402,18 @@ public sealed class PlayerMovementController
var jumpResult = _motion.jump(_jumpExtent);
if (jumpResult == WeenieError.None)
{
// Capture jump_v_z BEFORE LeaveGround() — that call resets
// JumpExtent back to 0 (faithful to retail's FUN_00529710),
// after which get_jump_v_z() returns 0 because the extent
// gate at the top of the function fires.
float jumpVz = _motion.get_jump_v_z();
_motion.LeaveGround();
outJumpExtent = _jumpExtent;
// BODY-LOCAL jump-launch velocity, computed directly from input.
//
// Why not read _body.Velocity? Because _motion.LeaveGround()
// routes through get_leave_ground_velocity → get_state_velocity,
// which is a faithful port of retail's FUN_00528960. Retail's
// version only handles WalkForward (0x45000005) / RunForward
// (0x44000007) / SideStepRight (0x6500000F); WalkBackwards
// and SideStepLeft return zero. Retail papers over this in
// adjust_motion (FUN_00528010) by translating
// WalkBackwards → WalkForward + speed × -0.65
// SideStepLeft → SideStepRight + speed × -1
// before they reach InterpretedState — but we don't yet port
// adjust_motion, so InterpretedState holds the un-translated
// command and get_state_velocity returns (0,0,0) for it.
// LeaveGround then writes (0,0,jumpZ) to the body, wiping the
// correct strafe/backward velocity the controller had just set
// a few lines up. Result: backward/strafe jumps go straight up.
//
// Until adjust_motion is ported, we mirror the grounded-velocity
// computation from the block above and stuff the result into
// outJumpVelocity directly. Local frame: +Y forward, +X right,
// +Z up — matches retail's body-frame convention. Server
// rotates body→world on receive, so observers see the jump
// in the correct world direction.
float jumpRunMul = 1.0f;
if (input.Run && _weenie.InqRunRate(out float jvrr))
jumpRunMul = jvrr;
// Forward uses get_state_velocity (which knows Walk vs Run vs
// animation-cycle pacing). Backward / Strafe use the same
// hardcoded scaled formulas the grounded-velocity block above
// uses (lines 397-408).
float localY = 0f;
if (input.Forward)
{
var stateVel = _motion.get_state_velocity();
localY = stateVel.Y;
}
else if (input.Backward)
{
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * jumpRunMul);
}
float localX = 0f;
if (input.StrafeRight)
localX = MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
else if (input.StrafeLeft)
localX = -MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
outJumpVelocity = new Vector3(localX, localY, jumpVz);
// Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ)
// to the body for backward/strafe-left (same get_state_velocity
// zero-for-non-canonical-motion bug as on the wire side).
// Push the corrected body-local velocity back so the local
// client renders the jump in the same world direction the
// server is broadcasting to observers. Same vector we just
// sent in JumpAction — local + remote stay in sync.
_body.set_local_velocity(outJumpVelocity.Value);
outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it
}
_jumpCharging = false;
_jumpExtent = 0f;
}
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
//
// L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj::
// update_object skips integration when accumulated dt is below
// MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz
// render. We accumulate per-frame dt and only integrate (with the
// accumulated dt) when the threshold is reached. See _physicsAccum
// declaration for the full retail trace evidence.
var preIntegratePos = _body.Position;
_physicsAccum += dt;
if (_physicsAccum > PhysicsBody.HugeQuantum)
{
// Stale frame (debugger break, GC pause). Discard accumulated dt.
_physicsAccum = 0f;
}
else if (_physicsAccum >= PhysicsBody.MinQuantum)
{
// Integrate accumulated dt, clamped to MaxQuantum so a long
// pause doesn't produce one giant integration step.
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
_physicsAccum -= tickDt;
}
// Else: dt below MinQuantum threshold — skip integration. Position
// and velocity remain unchanged; Resolve below runs as a zero-distance
// sphere sweep (no collision possible) and the rest of the frame
// (motion commands, animation, return) runs normally.
_body.calc_acceleration();
_body.UpdatePhysicsInternal(dt);
var postIntegratePos = _body.Position;
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
@ -560,19 +441,6 @@ public sealed class PlayerMovementController
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
// L.4-diag (2026-04-30): trace position transitions so we can see
// whether the body is actually moving frame-to-frame on the steep
// roof, or whether it's frozen at the impact point.
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
&& resolveResult.CollisionNormalValid)
{
Console.WriteLine(
$"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " +
$"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " +
$"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " +
$"isOnGround={resolveResult.IsOnGround}");
}
// Apply resolved position.
_body.Position = resolveResult.Position;
@ -639,21 +507,6 @@ public sealed class PlayerMovementController
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
if (diagSteep && resolveResult.CollisionNormalValid)
{
var n0 = resolveResult.CollisionNormal;
var v0 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
$"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
$"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
$"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
$"dot={Vector3.Dot(v0, n0):F3} " +
$"isOnGround={resolveResult.IsOnGround}");
}
if (applyBounce)
{
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
@ -673,13 +526,6 @@ public sealed class PlayerMovementController
// velocity reflects (subtle bounce).
float k = -(dotVN * (_body.Elasticity + 1f));
_body.Velocity = v + n * k;
if (diagSteep)
{
var v1 = _body.Velocity;
Console.WriteLine(
$"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
}
}
}
}
@ -830,19 +676,21 @@ public sealed class PlayerMovementController
return System.Math.Abs(a.Value - b.Value) < 1e-4f;
}
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
// Holtburger fires AutonomousPosition heartbeat at 1 Hz regardless of
// motion state (gated only by has_autonomous_position_sync_target).
// Retail's CommandInterpreter::SendPositionEvent gates on
// transient_state (Contact + OnWalkable + valid Position), not on
// motion. The pre-fix isMoving gate stopped acdream from heart-beating
// at rest, which left observers with stale last-known positions during
// long idle periods. PortalSpace (handled at the top of Update via
// early return) skips Update entirely, so reaching this line implies
// we're in a valid in-world pose.
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
// ── 8. Heartbeat timer (only while moving) ────────────────────────────
bool isMoving = outForwardCmd is not null
|| outSidestepCmd is not null
|| outTurnCmd is not null;
if (isMoving)
{
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
}
else
{
_heartbeatAccum = 0f;
HeartbeatDue = false;
}
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
// should match the actual movement speed. For Forward+Run this is
@ -867,14 +715,7 @@ public sealed class PlayerMovementController
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed,
// Run hold-key applies to ANY active directional axis, not just
// forward (per holtburger's build_motion_state_raw_motion_state:
// "uses the same value for every active per-axis hold key"). The
// pre-fix condition `input.Run && input.Forward` made strafe-run
// and backward-run incorrectly broadcast as walk to observers,
// who then animated walk + dead-reckoned at walk speed while the
// server position moved at run speed — visible as observer lag.
IsRunning: input.Run && anyDirectional,
IsRunning: input.Run && input.Forward,
LocalAnimationCommand: localAnimCmd,
LocalAnimationSpeed: localAnimSpeed,
JustLanded: justLanded,

View file

@ -168,10 +168,6 @@ public sealed class GameWindow : IDisposable
// Keep the experimental path available for DAT archaeology only.
private readonly bool _enableSkyPesDebug =
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_ENABLE_SKY_PES"), "1", StringComparison.Ordinal);
// Diagnostic: hide a specific humanoid part (>=10 parts) at render.
private static readonly int s_hidePartIndex =
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1;
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
private readonly HashSet<SkyPesKey> _missingSkyPes = new();
@ -1916,31 +1912,6 @@ public sealed class GameWindow : IDisposable
// then proceed with the normal upload loop.
var parts = new List<AcDream.Core.World.MeshRef>(flat);
var animPartChanges = spawn.AnimPartChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.AnimPartChange>();
// Diagnostic: dump AnimPartChanges + TextureChanges for humanoid setups
// gated on ACDREAM_DUMP_CLOTHING=1. Used to verify whether the server is
// sending coverage for the neck (part 9 for Aluvian Male) etc.
bool dumpClothing = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CLOTHING"), "1", StringComparison.Ordinal)
&& setup.Parts.Count >= 10;
if (dumpClothing)
{
Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} APC={animPartChanges.Count} ===");
foreach (var c in animPartChanges)
Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}");
// #37: per-spawn palette swaps. The server's clothing pipeline
// sends a basePalette + a list of (subPaletteId, offset, length)
// triples that splice palette ranges into the rendered character.
// We need their IDs to know whether the coat texture's underlying
// palette is being overridden by a coat-tone subPalette or left
// alone (in which case the texture's DefaultPaletteId — a SKIN
// palette — leaks through and the coat ends up neck-colored).
Console.WriteLine($" basePalette=0x{(spawn.BasePaletteId ?? 0):X8} subPalettes={(spawn.SubPalettes?.Count ?? 0)}");
if (spawn.SubPalettes is { } subPaletteList)
{
foreach (var subPal in subPaletteList)
Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}");
}
}
foreach (var change in animPartChanges)
{
if (change.PartIndex < parts.Count)
@ -1961,45 +1932,6 @@ public sealed class GameWindow : IDisposable
// to get a texture decoded with the replacement SurfaceTexture
// substituted inside the Surface's decode chain.
var textureChanges = spawn.TextureChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.TextureChange>();
if (dumpClothing)
{
Console.WriteLine($" TextureChanges count={textureChanges.Count}");
foreach (var tc in textureChanges)
Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}");
// For each part (post-AnimPartChange), dump its Surface chain so we
// can see which OrigTextureIds the part references and check which
// are covered by our TextureChanges.
var tcByPart = new Dictionary<int, HashSet<uint>>();
foreach (var tc in textureChanges)
{
if (!tcByPart.TryGetValue(tc.PartIndex, out var set)) { set = new HashSet<uint>(); tcByPart[tc.PartIndex] = set; }
set.Add(tc.OldTexture);
}
for (int pi = 0; pi < parts.Count; pi++)
{
var pgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(parts[pi].GfxObjId);
if (pgfx is null) continue;
if (pgfx.Surfaces.Count == 0) continue;
tcByPart.TryGetValue(pi, out var coveredOldTex);
int matched = 0;
int unmatched = 0;
var unmatchedList = new List<string>();
foreach (var surfQid in pgfx.Surfaces)
{
uint surfId = (uint)surfQid;
var surf = _dats.Get<DatReaderWriter.DBObjs.Surface>(surfId);
if (surf is null) continue;
uint origTex = (uint)surf.OrigTextureId;
if (coveredOldTex is not null && coveredOldTex.Contains(origTex)) matched++;
else { unmatched++; unmatchedList.Add($"surf=0x{surfId:X8} origTex=0x{origTex:X8}"); }
}
if (pgfx.Surfaces.Count > 0)
Console.WriteLine($" part[{pi:D2}] gfx=0x{parts[pi].GfxObjId:X8} surfaces={pgfx.Surfaces.Count} matched={matched} unmatched={unmatched}");
foreach (var s in unmatchedList)
Console.WriteLine($" UNMATCHED {s}");
}
}
Dictionary<int, Dictionary<uint, uint>>? resolvedOverridesByPart = null;
if (textureChanges.Count > 0)
{
@ -2736,14 +2668,7 @@ public sealed class GameWindow : IDisposable
// get_state_velocity returns 0 because the gate is
// RunForward||WalkForward — body stops moving forward.
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
// Pass speedMod through verbatim — preserve sign so retail's
// adjust_motion'd backward walk (cmd=WalkForward, spd<0)
// produces backward velocity in get_state_velocity, NOT
// forward. Pre-fix used `<=0 ? 1 : speedMod` which clamped
// negative to 1.0 and made the dead-reckoned body translate
// forward despite the reverse-playback animation — visually
// "still walking forward" from the observer's POV.
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod;
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod;
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path)
@ -6111,10 +6036,6 @@ public sealed class GameWindow : IDisposable
partTransform = partTransform * scaleMat;
var template = ae.PartTemplate[i];
if (s_hidePartIndex >= 0 && i == s_hidePartIndex && partCount >= 10)
{
continue;
}
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
{
SurfaceOverrides = template.SurfaceOverrides,

View file

@ -207,11 +207,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
}
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling entirely.
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
foreach (var (key, grp) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
@ -273,19 +268,9 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling (used 2026-05-01
// to test if our mesh winding (0,i,i+1) vs ACME's (i+1,i,0) is causing
// visible polygons to be culled, especially around the neck/coat seam).
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
else
{
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
}
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
foreach (var (key, grp) in _groups)
{

View file

@ -389,18 +389,11 @@ public sealed class AnimationSequencer
// This keeps the run/walk loop smooth when a new UpdateMotion arrives
// with a different ForwardSpeed (e.g. when the server broadcasts a
// player's updated RunRate mid-step).
//
// **Sign-flip case (2026-05-02):** when the server sends adjust_motion'd
// backward walk as `WalkForward + speed=-N`, motion stays 0x45000005
// but speedMod sign flips. We MUST do a full cycle restart in that case
// so the new (negative) framerate takes effect; otherwise the cycle
// keeps playing forward with the old positive framerate and the
// observer sees the player walking forward despite the negative speed.
if (CurrentStyle == style && CurrentMotion == motion
&& _firstCyclic != null && _queue.Count > 0
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod))
&& _firstCyclic != null && _queue.Count > 0)
{
if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod)
&& MathF.Abs(CurrentSpeedMod) > 1e-6f)
{
MultiplyCyclicFramerate(speedMod / CurrentSpeedMod);

View file

@ -1473,27 +1473,6 @@ public static class BSPQuery
if (changed && hitPoly is not null)
{
// ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale
//
// L.4 retail-strict (2026-04-30): the FloorZ gate previously
// here was REMOVED after the retail debugger trace + acdream
// wedge analysis showed it was preventing the natural escape
// path. Retail's flow:
// Frame N: airborne sphere hits steep poly. Path 4 commits
// ContactPlane on the steep poly (LandingZ ≈ 0.087 is
// permissive enough — even 49°+ slopes pass).
// Frame N+1: body now grounded with Contact + steep
// ContactPlane. OnWalkable cleared by FloorZ test
// downstream. Resolver fires Path 5 (Contact branch)
// instead of Path 6. step_sphere_up tries to step over,
// fails, falls back to step_up_slide → clears Contact,
// slides sphere laterally along StepUpNormal.
// Frame N+2: body airborne with lateral V from the slide.
// Gravity takes over, body falls off the slope.
//
// Without this Path-4 commit, the body NEVER gets Contact
// set, Path 5 never fires, step_up_slide never runs, and the
// body wedges in airborne-collision-revert-loop with V at
// MaxVelocity tangent to the surface (live wedge 2026-04-30).
var localOffset = validPos.Center - sphere0.Center;
var worldOffset = L2W(localOffset) * scale;
path.AddOffsetToCheckPos(worldOffset);
@ -1502,6 +1481,7 @@ public static class BSPQuery
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
@ -1592,57 +1572,16 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld);
}
// ─── SetCollide response ─────────────────────────────────
// Airborne sphere hits a polygon. Per retail, call SetCollide
// which saves backup position, records StepUpNormal = worldNormal,
// and sets WalkInterp=1. TransitionalInsert's Collide branch will
// then re-test as Placement to confirm we can land on the surface.
//
// ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
// + return Adjusted.
// Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
// L.4 slide-tangent for steep airborne hits (2026-04-30).
//
// For polygons too steep to walk on (worldNormal.Z < FloorZ),
// skip the SetCollide → Path-4 → ContactPlane landing chain.
// That chain commits the body to the steep surface, leading
// to the "stuck in falling animation on the roof" bug — once
// grounded with a steep ContactPlane, our step_up_slide /
// cliff_slide / edge_slide chain can't produce smooth
// descent and the body wedges or "falls a bit at a time"
// when bumped.
//
// Instead: project the move along the steep face (remove
// the into-wall displacement), set CollisionNormal +
// SlidingNormal, return Slid. Same shape as Path 5's
// step-up fallback (line 1545-1547) and CylinderCollision
// (TransitionTypes.cs:1518-1522). Position is updated in-
// place; on the next resolver iteration the sphere is
// outside the poly, FindCollisions returns OK, and
// ValidateTransition commits the new position. Body stays
// airborne, falling animation continues, and gravity's
// tangent component drifts the body downhill until it
// slides off the slope's edge.
//
// This is a deliberate deviation from retail (retail uses
// SetCollide unconditionally and lets find_walkable +
// step_up_slide produce the slide). Validated against
// retail debugger trace 2026-04-30: retail body did not
// wedge; our retail-faithful port DID wedge because we're
// missing implementation details of the step_up_slide /
// cliff_slide chain on grounded-steep movement. The
// slide-tangent here produces user-acceptable behavior
// (slides off naturally) while the deeper chain port is
// researched. Filed as L.5+ followup for retail-strict.
if (worldNormal0.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
Vector3 endWorld = path.GlobalSphere[0].Origin;
Vector3 gDelta = endWorld - currWorld;
float diff = Vector3.Dot(worldNormal0, gDelta);
if (diff < 0f)
path.AddOffsetToCheckPos(-worldNormal0 * diff);
collisions.SetCollisionNormal(worldNormal0);
collisions.SetSlidingNormal(worldNormal0);
return TransitionState.Slid;
}
// ─── SetCollide response (shallow / walkable) ───────────
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;
@ -1658,24 +1597,8 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
// Head sphere hit: same SetCollide response.
var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
// L.4 slide-tangent: same steep-poly slide for head-sphere.
if (worldNormal1.Z < PhysicsGlobals.FloorZ)
{
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
Vector3 endWorld = path.GlobalSphere[0].Origin;
Vector3 gDelta = endWorld - currWorld;
float diff = Vector3.Dot(worldNormal1, gDelta);
if (diff < 0f)
path.AddOffsetToCheckPos(-worldNormal1 * diff);
collisions.SetCollisionNormal(worldNormal1);
collisions.SetSlidingNormal(worldNormal1);
return TransitionState.Slid;
}
// Head sphere hit shallow surface: SetCollide.
path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;

View file

@ -594,25 +594,6 @@ public sealed class PhysicsEngine
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
// Phase 3's reset path sets VelocityKilled when an airborne hit
// can't find a walkable surface (steep roof, wall) AND the
// body had a last_known_contact_plane (i.e., was grounded
// recently). Retail zeros all three velocity components so
// gravity restarts cleanly next frame.
//
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
// acclient_2013_pseudo_c.txt:274467-274475
// Called from CTransition::transitional_insert reset path:
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
if (transition.ObjectInfo.VelocityKilled)
{
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
body.Velocity = Vector3.Zero;
}
}
// L.3a (2026-04-30): surface the wall normal so callers can apply

View file

@ -78,32 +78,13 @@ public sealed class ObjectInfo
public float GetWalkableZ()
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
/// <summary>
/// Sticky flag: set by <see cref="StopVelocity"/>; PhysicsEngine consumes
/// it after the transition commits to zero the body's velocity. Models
/// retail's <c>OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)</c>
/// (named-retail acclient_2013_pseudo_c.txt:274467-274475).
/// Cleared by the engine when consumed; reset to false at the start of
/// each <c>FindTransitionalPosition</c>.
/// </summary>
public bool VelocityKilled;
/// <summary>
/// Stop any accumulated velocity on this object info.
/// Retail: <c>OBJECTINFO::kill_velocity</c> calls
/// <c>CPhysicsObj::set_velocity(object, {0,0,0}, 0)</c>
/// (named-retail 0x50cfe0).
/// ACE: <c>ObjectInfo.StopVelocity</c> — clears Velocity on the physics body.
/// <para>
/// Velocity lives on <see cref="PhysicsBody"/>, not here. We can't reach
/// the body directly from inside the resolver without coupling
/// ObjectInfo to it, so we set a flag and let
/// <see cref="PhysicsEngine.ResolveWithTransition"/> apply the zero
/// after the transition completes. The flag is sticky across the
/// outer step loop and consumed exactly once per resolve.
/// </para>
/// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body.
/// acdream: velocity is tracked on PhysicsBody, not here. No-op for now;
/// will be wired when velocity is threaded through TransitionalInsert.
/// </summary>
public void StopVelocity() { VelocityKilled = true; }
public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
}
/// <summary>
@ -212,12 +193,6 @@ public sealed class SpherePath
public float WalkableAllowance = PhysicsGlobals.FloorZ;
public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
public bool LastWalkableValid;
public Plane LastWalkablePlane;
public Vector3[]? LastWalkableVertices;
public Vector3 LastWalkableUp = Vector3.UnitZ;
public bool HasLastWalkablePolygon => LastWalkableValid && LastWalkableVertices is { Length: >= 3 };
// Backup for restore
public Vector3 BackupCheckPos;
public uint BackupCheckCellId;
@ -281,11 +256,6 @@ public sealed class SpherePath
WalkableVertices = (Vector3[])vertices.Clone();
WalkableUp = up;
WalkableAllowance = PhysicsGlobals.FloorZ;
LastWalkableValid = true;
LastWalkablePlane = plane;
LastWalkableVertices = (Vector3[])vertices.Clone();
LastWalkableUp = up;
}
public void ClearWalkable()
@ -294,18 +264,6 @@ public sealed class SpherePath
WalkableVertices = null;
}
public bool RestoreLastWalkable()
{
if (!HasLastWalkablePolygon || LastWalkableVertices is null)
return false;
WalkableValid = true;
WalkablePlane = LastWalkablePlane;
WalkableVertices = (Vector3[])LastWalkableVertices.Clone();
WalkableUp = LastWalkableUp;
return true;
}
/// <summary>
/// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and runs the full sphere-slide computation
@ -458,13 +416,6 @@ public sealed class Transition
{
var sp = SpherePath;
// L.4 retail-strict (2026-04-30): clear the kill_velocity flag at
// the start of each resolve so leftover state from a prior
// transition doesn't carry over. Inside the loop, Phase 3's reset
// path may set this via OBJECTINFO::StopVelocity; the engine reads
// it after FindTransitionalPosition returns.
ObjectInfo.VelocityKilled = false;
// No starting cell → cannot move.
if (sp.CurCellId == 0)
return false;
@ -633,9 +584,6 @@ public sealed class Transition
// ── Phase 2: object (static BSP + cylinder) collision ───────
// Env was OK — now test objects.
var objState = FindObjCollisions(engine);
// L.4-diag: log Phase outcomes per attempt so we can see whether
// we're escaping to the step-down branch or churning in retries.
DumpPhase2(attempt, transitState, objState);
if (objState == TransitionState.Collided)
return TransitionState.Collided;
@ -702,50 +650,13 @@ public sealed class Transition
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
if (diagSteep)
{
Console.WriteLine(
$"[steep-roof] PHASE3-RESET lastKnownValid={ci.LastKnownContactPlaneValid} " +
$"checkPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2}) " +
$"curPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2}) " +
$"stepUpNormal=({sp.StepUpNormal.X:F2},{sp.StepUpNormal.Y:F2},{sp.StepUpNormal.Z:F2})");
}
// Retail-faithful gate (acclient_2013_pseudo_c.txt:273231-273239):
//
// if (last_known_valid == 0) {
// set_collision_normal(step_up_normal); return COLLIDED;
// }
// kill_velocity(this);
// last_known_valid = 0;
// return COLLIDED;
//
// kill_velocity ONLY fires when last_known was valid. When
// it's not (the case our L.2.4 proximity guard produces
// after a few airborne frames), velocity is PRESERVED so
// the bounce reflection in handle_all_collisions can
// redirect V's perpendicular component along the slope's
// tangent direction — that's how retail's body escapes
// the wedge geometry.
//
// This was deviated to "unconditional" earlier in this
// session as a hypothesis-driven fix; the live trace
// showed the deviation CAUSED the wedge by zeroing V
// every frame, leaving the body with no tangent momentum
// to escape (live diag 2026-04-30: V=(0,0,0) for 169
// consecutive frames while position pre/resolved frozen).
if (ci.LastKnownContactPlaneValid)
{
ci.LastKnownContactPlaneValid = false;
oi.StopVelocity();
if (diagSteep)
Console.WriteLine($"[steep-roof] PHASE3-RESET-KILLV ← StopVelocity called");
}
else
{
ci.SetCollisionNormal(sp.StepUpNormal);
}
return TransitionState.Collided;
}
@ -764,27 +675,7 @@ public sealed class Transition
// as in contact with the ground, but the current CheckPos has no
// terrain contact (walked off an edge). Attempt a step-down to
// maintain ground contact.
//
// L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane
// IS valid but the surface is too steep to walk on. This is the
// "player standing on a steep roof / steep terrain" case. Phase 1
// sets ContactPlane on the slope (geometric touch is enough — no
// walkable check), so without this clause the step-down branch
// skips and EdgeSlideAfterStepDownFailed never gets the chance to
// call CliffSlide. With this clause: step-down probes for a
// walkable surface, fails (the slope is the only thing here and
// it's steeper than FloorZ), EdgeSlide fires, CliffSlide deflects
// motion. Then gravity does the rest of the downhill drift.
//
// Retail's transitional_insert OK-path always runs the step-down
// chain (per agent reports of acclient_2013_pseudo_c.txt:273191).
// We approximate that by triggering it whenever the current contact
// is invalid OR steeper than walkable.
bool contactInvalidOrSteep = !ci.ContactPlaneValid
|| ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ;
// L.4-diag (2026-04-30): trace why we don't slide down roofs.
DumpStepDownBranchGate(contactInvalidOrSteep);
if (contactInvalidOrSteep && oi.Contact && !sp.StepDown
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
{
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
@ -842,23 +733,7 @@ public sealed class Transition
// we are missing precipice context, a steep contact plane, or
// merely the EdgeSlide flag.
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
var edgeState = EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
if (edgeState == TransitionState.Slid)
{
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
continue;
}
if (edgeState == TransitionState.Adjusted)
{
sp.NegPolyHit = false;
continue;
}
return edgeState;
return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
}
return TransitionState.OK;
@ -878,41 +753,10 @@ public sealed class Transition
var ci = CollisionInfo;
var oi = ObjectInfo;
// L.4-cliffslide-priority (2026-04-30): the steep-ContactPlane check
// moved BEFORE the OnWalkable/EdgeSlide gate.
//
// Why: by the time this dispatch runs on subsequent frames (player
// standing on a steep slope), ValidateTransition's L.2.3i FloorZ
// test has already CLEARED OnWalkable (steep slope → not a walkable
// surface). The original Branch 1 (`!OnWalkable → restore + OK`)
// therefore fires every frame, stopping the player dead — exactly
// the "stay on the roof" symptom the user reported.
//
// Re-ordering: if the surface is too steep AND we have a contact
// plane on it, run CliffSlide regardless of OnWalkable. The
// cross(currentNormal, lastKnownNormal) deflection plus gravity
// produces visible downhill drift each frame.
//
// Branch 1 (the !OnWalkable stop) still fires when we DON'T have
// a contact plane — the original "walked off into thin air"
// case, which should still stop or fall normally rather than
// CliffSlide on nothing.
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide)
{
var cliffPlane = ci.ContactPlane;
DumpEdgeSlideBranch("priority/steep-cliffslide", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return CliffSlide(cliffPlane);
}
// Retail lets non-EdgeSlide movers continue over the boundary. Player
// movement carries EdgeSlide, so the local avatar takes the slide path.
if (!oi.OnWalkable || !oi.EdgeSlide)
{
DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
@ -923,7 +767,6 @@ public sealed class Transition
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal)
{
var cliffPlane = ci.ContactPlane;
DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
@ -931,37 +774,8 @@ public sealed class Transition
return CliffSlide(cliffPlane);
}
if (!sp.HasWalkablePolygon)
sp.RestoreLastWalkable();
if (sp.HasWalkablePolygon)
{
// L.4-walkable-steep (2026-04-30): the stored Walkable polygon
// can be a too-steep surface (e.g., a roof the player jumped
// onto — Path 4's airborne-landing branch uses LandingZ, the
// permissive 0.087 threshold, so steep roofs get accepted as
// "walkable" for the landing). On subsequent frames the player
// is STANDING ON that polygon, not crossing its edge, so
// PrecipiceSlide's find_crossed_edge returns false and the
// player gets stuck in a Collided revert loop.
//
// Detect the case: if the walkable polygon's plane is steeper
// than FloorZ, route to CliffSlide using that plane instead of
// PrecipiceSlide. CliffSlide deflects motion along the ridge
// between current-steep and last-known-walkable; gravity then
// produces visible downhill drift.
if (sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ)
{
var cliffPlane = sp.WalkablePlane;
DumpEdgeSlideBranch("walkable-poly-steep-cliffslide", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return CliffSlide(cliffPlane);
}
DumpEdgeSlideBranch("branch3/precipice-slide", zVal);
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return sp.PrecipiceSlide(this);
@ -969,7 +783,6 @@ public sealed class Transition
if (ci.ContactPlaneValid)
{
DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal);
sp.ClearWalkable();
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
@ -989,9 +802,6 @@ public sealed class Transition
ci.ContactPlaneIsWater = false;
sp.RestoreCheckPos();
if (!sp.HasWalkablePolygon)
sp.RestoreLastWalkable();
if (sp.HasWalkablePolygon)
return sp.PrecipiceSlide(this);
@ -1004,53 +814,20 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
// L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane
// as the cross-product reference, falling back to world-up when no
// walkable history is available. Without this, when the player has
// been on a steep slope for >1 frame, ValidateTransition's L.2.3i
// FloorZ test propagates the steep plane into LastKnownContactPlane,
// so cross(currentSteep, lastKnownSteep) = 0 → degenerate, no
// deflection. Using LastWalkable preserves the prior flat-ground
// plane across continuous-slope frames; world-up gives a guaranteed
// non-zero deflection when no walkable history exists at all.
Vector3 referenceNormal;
string refSource;
if (sp.HasLastWalkablePolygon && sp.LastWalkablePlane.Normal.Z >= PhysicsGlobals.FloorZ)
{
referenceNormal = sp.LastWalkablePlane.Normal;
refSource = "last-walkable";
}
else if (ci.LastKnownContactPlaneValid && ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
{
referenceNormal = ci.LastKnownContactPlane.Normal;
refSource = "last-known-walkable";
}
else
{
// Fallback: world up. cross(steepNormal, UnitZ) gives the
// ridge direction (horizontal contour line of the slope).
// collideNormal then becomes the downhill horizontal axis.
referenceNormal = Vector3.UnitZ;
refSource = "world-up-fallback";
}
if (!ci.LastKnownContactPlaneValid)
return TransitionState.OK;
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal);
contactNormal.Z = 0f;
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane,
new Plane(referenceNormal, 0f), contactNormal, 0f, false);
return TransitionState.OK;
}
collideNormal = Vector3.Normalize(collideNormal);
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
float angle = Vector3.Dot(collideNormal, offset);
DumpCliffSlide($"ok/{refSource}", contactPlane,
new Plane(referenceNormal, 0f), collideNormal, angle, true);
if (angle <= 0f)
{
@ -1076,74 +853,7 @@ public sealed class Transition
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
}
/// <summary>
/// L.4-diag: log step-down branch gate decision. Whether we entered or
/// skipped the contact-recovery branch matters for whether CliffSlide
/// has any chance of firing.
/// </summary>
private void DumpStepDownBranchGate(bool contactInvalidOrSteep)
{
if (!DumpEdgeSlideEnabled) return;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown;
if (!wouldEnter) return; // only log when entering, to keep noise low
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: stepdown-branch-enter cur={Fmt(sp.CurPos)} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} contact={oi.Contact}"));
}
/// <summary>
/// L.4-diag: log Phase 2 outcome per inner attempt. Tells us whether
/// we're churning in Slid retries or escaping to step-down branch.
/// </summary>
private void DumpPhase2(int attempt, TransitionState envState, TransitionState objState)
{
if (!DumpEdgeSlideEnabled) return;
if (objState == TransitionState.OK) return; // skip clean attempts
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: phase2 attempt={attempt} env={envState} obj={objState}"));
}
/// <summary>
/// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired.
/// Tells us whether CliffSlide gets called or whether we hit a
/// stop-at-edge branch.
/// </summary>
private void DumpEdgeSlideBranch(string branch, float zVal)
{
if (!DumpEdgeSlideEnabled) return;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: branch={branch} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} lastValid={ci.LastKnownContactPlaneValid} lastN.Z={(ci.LastKnownContactPlaneValid ? ci.LastKnownContactPlane.Normal.Z : 0f):F3} walkPolyValid={sp.HasWalkablePolygon} walkPolyN.Z={(sp.HasWalkablePolygon ? sp.WalkablePlane.Normal.Z : 0f):F3} lastWalkPolyN.Z={(sp.HasLastWalkablePolygon ? sp.LastWalkablePlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} edgeFlag={oi.EdgeSlide} zVal={zVal:F3}"));
}
/// <summary>
/// L.4-diag: log CliffSlide invocation. Tells us whether the
/// cross-product is degenerate (no slide) or producing a real
/// deflection.
/// </summary>
private void DumpCliffSlide(string outcome, Plane current, Plane lastKnown,
Vector3 collideNormal, float angle, bool willApply)
{
if (!DumpEdgeSlideEnabled) return;
Console.WriteLine(
System.FormattableString.Invariant(
$"edge-slide: cliffslide outcome={outcome} curN={Fmt(current.Normal)} lastN={Fmt(lastKnown.Normal)} collideN={Fmt(collideNormal)} angle={angle:F4} apply={willApply}"));
$"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
}
private static string Fmt(Vector3 value) =>
@ -1633,18 +1343,7 @@ public sealed class Transition
else if (ci.LastKnownContactPlaneValid)
contactPlane = ci.LastKnownContactPlane;
else
{
// Airborne wall-only hit: retail normally reaches this with a
// LastKnownContactPlane from CPhysicsObj::get_object_info when the
// object is still in Contact. Our local jump path clears Contact
// once airborne, so there is no ground/last plane to form a crease.
// Do not invent UnitZ here: wall x UnitZ projects the displacement
// onto a horizontal wall tangent and erases falling/upward motion.
float diff = Vector3.Dot(collisionNormal, gDelta);
Vector3 offset = -collisionNormal * diff;
sp.AddOffsetToCheckPos(offset);
return TransitionState.Slid;
}
contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f);
// Crease direction = cross(collisionNormal, contactPlane.Normal).
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
@ -2151,44 +1850,12 @@ public sealed class Transition
// contact is still valid — keep the mover grounded via the
// last-known plane. Without this, every wall bump dropped the
// player into the falling animation for one frame.
//
// L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the
// last-known plane if the sphere is still actually near it.
// Geometrically: `angle` is the signed distance from the
// sphere center to the plane. If |angle| exceeds the sphere
// radius (plus a tiny epsilon), the sphere has SEPARATED
// from the plane — typically because we fell off an edge or
// the body dropped vertically while the resolver bounced
// through edge-slide attempts. Without this guard the player
// gets stuck mid-fall in a falling animation forever (live
// bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the
// remembered floor, but still being marked Contact + OnWalkable).
//
// Matches ACE PhysicsObj's pre-reuse check on the last-known
// plane and retail's CPhysicsObj::get_object_info logic.
var sphereCenter = sp.GlobalSphere[0].Origin;
var radius = sp.GlobalSphere[0].Radius;
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
+ ci.LastKnownContactPlane.D;
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
{
// Still close enough to the last-known plane — preserve
// grounded state. L.2.3i FloorZ test for OnWalkable.
oi.State |= ObjectInfoState.Contact;
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
else
oi.State &= ~ObjectInfoState.OnWalkable;
}
oi.State |= ObjectInfoState.Contact;
// L.2.3i: same FloorZ correction as the live-contact branch.
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
else
{
// Sphere has separated from the last-known plane.
// Drop the memory and let the body resolve normally
// (gravity → next-frame terrain probe → real contact).
ci.LastKnownContactPlaneValid = false;
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
}
oi.State &= ~ObjectInfoState.OnWalkable;
}
else
{

View file

@ -506,85 +506,6 @@ public class BSPStepUpTests
"indicates Path 5 recursing through DoStepUp without guard.");
}
/// <summary>
/// L.2c regression: an airborne mover jumping/falling into a vertical wall
/// must keep its vertical displacement. With no live or last-known contact
/// plane, SlideSphere must remove only the component into the wall; inventing
/// a flat UnitZ plane projects the displacement onto the wall/floor crease
/// and leaves the character stuck in falling animation against the wall.
/// </summary>
[Fact]
public void D3_AirborneMover_TallWall_PreservesVerticalMotion()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var from = new Vector3(0.1f, 0f, 2.0f);
var to = new Vector3(0.6f, 0f, 1.5f);
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
t.FindTransitionalPosition(engine);
Assert.True(t.SpherePath.CurPos.Z < from.Z - 0.1f,
$"Expected airborne wall-slide to preserve downward motion; " +
$"from.Z={from.Z:F3}, CurPos.Z={t.SpherePath.CurPos.Z:F3}");
Assert.True(t.SpherePath.CurPos.X <= 0.5f - BSPStepUpFixtures.SphereRadius + PhysicsGlobals.EPSILON * 20f,
$"Expected wall to block X penetration; got CurPos.X={t.SpherePath.CurPos.X:F3}");
}
/// <summary>
/// L.2c regression: if an airborne wall collision happens in a one-substep
/// frame, the collision normal has to survive into the next frame. Retail
/// does this with transient_state bit 2 + InitSlidingNormal. Without that,
/// every frame replays the same hard stop and the character hangs in falling
/// animation until another correction breaks the loop.
/// </summary>
[Fact]
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
var body = new PhysicsBody
{
Position = new Vector3(0.25f, 0f, 2.0f),
TransientState = TransientStateFlags.Active,
};
var frame1 = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: new Vector3(0.36f, 0f, 1.92f),
cellId: 0xA9B40001u,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 0f,
stepUpHeight: 0.04f,
stepDownHeight: 0.04f,
isOnGround: false,
body: body);
body.Position = frame1.Position;
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"First airborne wall hit should cache SlidingNormal for the next frame.");
Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
var frame2 = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: body.Position + new Vector3(0.11f, 0f, -0.08f),
cellId: 0xA9B40001u,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 0f,
stepUpHeight: 0.04f,
stepDownHeight: 0.04f,
isOnGround: false,
body: body);
Assert.True(frame2.Position.Z < frame1.Position.Z - 0.05f,
$"Expected cached wall-slide normal to allow falling on frame 2; " +
$"frame1.Z={frame1.Position.Z:F3}, frame2.Z={frame2.Position.Z:F3}");
Assert.InRange(frame2.Position.X, 0.24f, 0.31f);
}
// =========================================================================
// Helpers
// =========================================================================

View file

@ -261,52 +261,6 @@ public class PhysicsEngineTests
Assert.Equal(50f, result.Position.Z, precision: 2);
}
[Fact]
public void ResolveWithTransition_EdgeSlideAtLoadedTerrainBoundary_PreservesTangentMotion()
{
var engine = MakeFlatEngine(terrainZ: 50f);
var body = new PhysicsBody
{
Position = new Vector3(191f, 96f, 50f),
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
ContactPlaneValid = true,
ContactPlane = new Plane(Vector3.UnitZ, -50f),
ContactPlaneCellId = 0x003Du,
};
var settled = engine.ResolveWithTransition(
currentPos: new Vector3(191f, 96f, 50f),
targetPos: new Vector3(191.25f, 96f, 50f),
cellId: 0x003Du,
sphereRadius: 0.5f,
sphereHeight: 1.2f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.EdgeSlide);
Assert.True(body.WalkablePolygonValid);
Assert.NotNull(body.WalkableVertices);
var result = engine.ResolveWithTransition(
currentPos: settled.Position,
targetPos: new Vector3(193f, 98f, 50f),
cellId: 0x003Du,
sphereRadius: 0.5f,
sphereHeight: 1.2f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.EdgeSlide);
Assert.True(result.IsOnGround);
Assert.InRange(result.Position.X, 190.75f, 192.0001f);
Assert.True(result.Position.Y > 96.2f);
Assert.Equal(50f, result.Position.Z, precision: 2);
}
[Fact]
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
{

View file

@ -1,119 +0,0 @@
"""Check an .exe's CodeView debug info to see what PDB GUID + age it
expects. Used to verify whether a candidate acclient.exe matches our
acclient.pdb without running the binary.
Usage:
py tools/pdb-extract/check_exe_pdb.py <path-to-exe>
"""
import struct
import sys
import uuid
def main():
if len(sys.argv) < 2:
print("usage: check_exe_pdb.py <path-to-exe>")
sys.exit(1)
with open(sys.argv[1], "rb") as f:
data = f.read()
# DOS header -> e_lfanew @ offset 0x3C points to PE header
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe_off:pe_off + 4] == b"PE\x00\x00", "not a PE file"
# COFF header
machine = struct.unpack_from("<H", data, pe_off + 4)[0]
n_sections = struct.unpack_from("<H", data, pe_off + 6)[0]
timestamp = struct.unpack_from("<I", data, pe_off + 8)[0]
opt_size = struct.unpack_from("<H", data, pe_off + 20)[0]
print(f"machine = 0x{machine:04x}")
print(f"timestamp = 0x{timestamp:08x} ({timestamp})")
import datetime
ts = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
print(f" -> linker UTC: {ts.isoformat()}")
# Optional header — magic indicates 32 vs 64 bit
opt_off = pe_off + 24
magic = struct.unpack_from("<H", data, opt_off)[0]
is_pe32_plus = (magic == 0x20B)
print(f"opt magic = 0x{magic:04x} ({'PE32+' if is_pe32_plus else 'PE32'})")
# Data directories: PE32 has them at opt_off + 96; PE32+ at opt_off + 112
dd_off = opt_off + (112 if is_pe32_plus else 96)
# Debug directory is data dir [6]
debug_va = struct.unpack_from("<I", data, dd_off + 6 * 8)[0]
debug_size = struct.unpack_from("<I", data, dd_off + 6 * 8 + 4)[0]
print(f"debug dir = VA=0x{debug_va:08x} size={debug_size}")
# We need to map the VA back to a file offset via section headers
sec_off = opt_off + opt_size
sections = []
for i in range(n_sections):
s = sec_off + i * 40
name = data[s:s + 8].rstrip(b"\x00").decode("ascii", errors="replace")
vsize = struct.unpack_from("<I", data, s + 8)[0]
vaddr = struct.unpack_from("<I", data, s + 12)[0]
rsize = struct.unpack_from("<I", data, s + 16)[0]
roff = struct.unpack_from("<I", data, s + 20)[0]
sections.append((name, vaddr, vsize, roff, rsize))
def va_to_file(va):
for (name, vaddr, vsize, roff, rsize) in sections:
if vaddr <= va < vaddr + vsize:
return roff + (va - vaddr)
return None
debug_off = va_to_file(debug_va)
if debug_off is None:
print("debug directory VA does not map into any section")
return
# Each debug directory entry is 28 bytes
n_entries = debug_size // 28
print(f"# debug entries = {n_entries}")
for i in range(n_entries):
e = debug_off + i * 28
characteristics = struct.unpack_from("<I", data, e)[0]
ts_e = struct.unpack_from("<I", data, e + 4)[0]
major = struct.unpack_from("<H", data, e + 8)[0]
minor = struct.unpack_from("<H", data, e + 10)[0]
type_e = struct.unpack_from("<I", data, e + 12)[0]
sz = struct.unpack_from("<I", data, e + 16)[0]
rva = struct.unpack_from("<I", data, e + 20)[0]
ptr = struct.unpack_from("<I", data, e + 24)[0]
type_name = {2: "CODEVIEW", 4: "MISC", 12: "VC_FEATURE", 13: "POGO", 16: "REPRO"}.get(type_e, f"type_{type_e}")
print(f" entry {i}: type={type_name} sz={sz} fileOff=0x{ptr:08x}")
if type_e == 2 and sz >= 24:
cv = data[ptr:ptr + sz]
sig = cv[:4]
print(f" cv signature = {sig!r}")
if sig == b"RSDS":
guid_bytes = cv[4:20]
age = struct.unpack_from("<I", cv, 20)[0]
pdb_name = cv[24:].rstrip(b"\x00").decode("utf-8", errors="replace")
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
print(f" GUID = {{{pdb_guid}}}")
print(f" age = {age}")
print(f" PDB filename = {pdb_name}")
expected_guid = uuid.UUID("9e847e2f-777c-4bd9-886c-22256bb87f32")
expected_age = 1
if pdb_guid == expected_guid and age == expected_age:
print()
print("=== MATCH: this exe pairs with our acclient.pdb ===")
else:
print()
print("=== MISMATCH ===")
print(f" expected GUID = {{{expected_guid}}}")
print(f" expected age = {expected_age}")
if __name__ == "__main__":
main()

View file

@ -1,98 +0,0 @@
"""Dump the PDB info stream so we know exactly which acclient.exe build
matches our PDB GUID. The PDB header points to stream 1 ("PDB Info") which
contains: u32 version, u32 signature(timestamp), u32 age, 16-byte GUID.
Usage:
py tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb
"""
import struct
import sys
import datetime
import uuid
def _ceil_div(a, b):
return (a + b - 1) // b
def main():
if len(sys.argv) < 2:
print("usage: dump_pdb_info.py <path-to-pdb>")
sys.exit(1)
pdb_path = sys.argv[1]
with open(pdb_path, "rb") as f:
data = f.read()
magic = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\x00\x00\x00"
assert data.startswith(magic), "not an MSF 7.00 PDB"
block_size = struct.unpack_from("<I", data, 0x20)[0]
num_blocks = struct.unpack_from("<I", data, 0x28)[0]
num_dir_bytes = struct.unpack_from("<I", data, 0x2C)[0]
block_map_addr = struct.unpack_from("<I", data, 0x34)[0]
print(f"block_size = {block_size}")
print(f"num_blocks = {num_blocks}")
print(f"num_dir_bytes = {num_dir_bytes}")
print(f"block_map_addr = {block_map_addr}")
def read_page(idx):
return data[idx * block_size : (idx + 1) * block_size]
dir_pages_needed = _ceil_div(num_dir_bytes, block_size)
block_map = read_page(block_map_addr)
dir_page_indices = struct.unpack_from(f"<{dir_pages_needed}I", block_map, 0)
dir_data = bytearray()
for pi in dir_page_indices:
dir_data.extend(read_page(pi))
dir_data = bytes(dir_data)
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
stream_sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
print(f"num_streams = {num_streams}")
offset = 4 + num_streams * 4
streams = []
for sz in stream_sizes:
if sz == 0xFFFFFFFF:
streams.append((0, []))
continue
n_pages = _ceil_div(sz, block_size)
pages = struct.unpack_from(f"<{n_pages}I", dir_data, offset)
offset += n_pages * 4
streams.append((sz, list(pages)))
# Stream 1 = PDB Info Stream
pdb_info_size, pdb_info_pages = streams[1]
print(f"pdb_info_size = {pdb_info_size}")
pdb_info = bytearray()
for pi in pdb_info_pages:
pdb_info.extend(read_page(pi))
pdb_info = bytes(pdb_info[:pdb_info_size])
version = struct.unpack_from("<I", pdb_info, 0)[0]
signature = struct.unpack_from("<I", pdb_info, 4)[0]
age = struct.unpack_from("<I", pdb_info, 8)[0]
guid_bytes = pdb_info[12:28]
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
sig_dt = datetime.datetime.fromtimestamp(signature, tz=datetime.timezone.utc)
print()
print("=== PDB Info Stream ===")
print(f"version = {version}")
print(f"signature = 0x{signature:08x} ({signature})")
print(f" -> linker timestamp UTC: {sig_dt.isoformat()}")
print(f"age = {age}")
print(f"GUID = {{{pdb_guid}}}")
print()
print("This is the GUID + age the matching acclient.exe must reference")
print("in its CodeView entry. Find a binary whose linker timestamp")
print(f"is around {sig_dt.strftime('%Y-%m-%d')}.")
if __name__ == "__main__":
main()