Compare commits
10 commits
1abb699c68
...
77b59d89e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b59d89e2 | ||
|
|
17a9ff1158 | ||
|
|
09e013b7bd | ||
|
|
3361641655 | ||
|
|
86e2a4dc90 | ||
|
|
235de3322a | ||
|
|
b1af56eb19 | ||
|
|
5210bd3d55 | ||
|
|
a48883af2d | ||
|
|
52e257d8d7 |
20 changed files with 2424 additions and 65 deletions
23
.gitignore
vendored
23
.gitignore
vendored
|
|
@ -41,3 +41,26 @@ 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
177
CLAUDE.md
|
|
@ -112,6 +112,51 @@ 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.**
|
||||
|
|
@ -119,10 +164,22 @@ 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.**
|
||||
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).**
|
||||
|
||||
### 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
|
||||
|
|
@ -204,6 +261,124 @@ 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
|
||||
|
|
|
|||
345
docs/ISSUES.md
345
docs/ISSUES.md
|
|
@ -46,6 +46,164 @@ 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
|
||||
|
|
@ -192,12 +350,48 @@ 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`; `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`.
|
||||
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`.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
|
||||
`src/AcDream.Core/Physics/BSPQuery.cs`,
|
||||
|
|
@ -211,6 +405,139 @@ 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
|
||||
|
|
@ -265,6 +592,8 @@ 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)
|
||||
|
|
@ -388,6 +717,8 @@ 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
|
||||
|
|
@ -415,6 +746,8 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-04-29 for Phase L.2 movement/collision conformance planning.
|
||||
**Status:** Living document. Updated 2026-05-02 for Phase M network-stack 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,6 +403,101 @@ 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.
|
||||
|
|
@ -486,6 +581,12 @@ 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** |
|
||||
|
|
|
|||
|
|
@ -122,8 +122,11 @@ 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`. Remaining L.2c work is real-DAT building-edge
|
||||
fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
|
||||
`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.
|
||||
|
||||
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects
|
||||
|
||||
|
|
|
|||
187
docs/plans/2026-04-30-sky-pes-port.md
Normal file
187
docs/plans/2026-04-30-sky-pes-port.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# 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.
|
||||
233
docs/research/2026-05-01-retail-motion-trace/findings.md
Normal file
233
docs/research/2026-05-01-retail-motion-trace/findings.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# 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, 0–360 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 0–360 | float degrees 0–360 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.
|
||||
267
docs/research/2026-05-01-retail-motion-trace/fixes.md
Normal file
267
docs/research/2026-05-01-retail-motion-trace/fixes.md
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# 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 0–360 | float degrees 0–360 | float degrees 0–360 | ✓ 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 0–360°).
|
||||
- **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 765–779: `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.
|
||||
|
|
@ -72,3 +72,9 @@ 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.
|
||||
|
|
|
|||
|
|
@ -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); // world-space launch velocity (sent in jump packet)
|
||||
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.
|
||||
|
||||
/// <summary>
|
||||
/// Portal-space state for the player movement controller.
|
||||
|
|
@ -168,10 +168,41 @@ 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 = 0.2f; // 200ms
|
||||
public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger
|
||||
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;
|
||||
|
|
@ -402,18 +433,106 @@ 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;
|
||||
outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it
|
||||
// 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);
|
||||
}
|
||||
_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;
|
||||
_body.calc_acceleration();
|
||||
_body.UpdatePhysicsInternal(dt);
|
||||
_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.
|
||||
var postIntegratePos = _body.Position;
|
||||
|
||||
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
|
||||
|
|
@ -441,6 +560,19 @@ 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;
|
||||
|
||||
|
|
@ -507,6 +639,21 @@ 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))
|
||||
|
|
@ -526,6 +673,13 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -676,21 +830,19 @@ public sealed class PlayerMovementController
|
|||
return System.Math.Abs(a.Value - b.Value) < 1e-4f;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
// ── 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;
|
||||
|
||||
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
|
||||
// should match the actual movement speed. For Forward+Run this is
|
||||
|
|
@ -715,7 +867,14 @@ public sealed class PlayerMovementController
|
|||
ForwardSpeed: outForwardSpeed,
|
||||
SidestepSpeed: outSidestepSpeed,
|
||||
TurnSpeed: outTurnSpeed,
|
||||
IsRunning: input.Run && input.Forward,
|
||||
// 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,
|
||||
LocalAnimationCommand: localAnimCmd,
|
||||
LocalAnimationSpeed: localAnimSpeed,
|
||||
JustLanded: justLanded,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,10 @@ 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();
|
||||
|
||||
|
|
@ -1912,6 +1916,31 @@ 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)
|
||||
|
|
@ -1932,6 +1961,45 @@ 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)
|
||||
{
|
||||
|
|
@ -2668,7 +2736,14 @@ 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;
|
||||
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod;
|
||||
// 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;
|
||||
|
||||
if (update.MotionState.IsServerControlledMoveTo
|
||||
&& update.MotionState.MoveToPath is { } path)
|
||||
|
|
@ -6036,6 +6111,10 @@ 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,
|
||||
|
|
|
|||
|
|
@ -207,6 +207,11 @@ 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))
|
||||
|
|
@ -268,9 +273,19 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.DepthMask(false);
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
// 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);
|
||||
}
|
||||
|
||||
foreach (var (key, grp) in _groups)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -389,11 +389,18 @@ 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)
|
||||
&& _firstCyclic != null && _queue.Count > 0
|
||||
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod))
|
||||
{
|
||||
if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f
|
||||
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod)
|
||||
&& MathF.Abs(CurrentSpeedMod) > 1e-6f)
|
||||
{
|
||||
MultiplyCyclicFramerate(speedMod / CurrentSpeedMod);
|
||||
|
|
|
|||
|
|
@ -1473,6 +1473,27 @@ 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);
|
||||
|
|
@ -1481,7 +1502,6 @@ 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;
|
||||
|
|
@ -1572,16 +1592,57 @@ 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;
|
||||
|
|
@ -1597,8 +1658,24 @@ 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;
|
||||
|
|
|
|||
|
|
@ -594,6 +594,25 @@ 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
|
||||
|
|
|
|||
|
|
@ -79,12 +79,31 @@ public sealed class ObjectInfo
|
|||
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
|
||||
|
||||
/// <summary>
|
||||
/// Stop any accumulated velocity on this object info.
|
||||
/// 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.
|
||||
/// 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 void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
|
||||
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>
|
||||
/// </summary>
|
||||
public void StopVelocity() { VelocityKilled = true; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -193,6 +212,12 @@ 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;
|
||||
|
|
@ -256,6 +281,11 @@ 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()
|
||||
|
|
@ -264,6 +294,18 @@ 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
|
||||
|
|
@ -416,6 +458,13 @@ 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;
|
||||
|
|
@ -584,6 +633,9 @@ 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;
|
||||
|
|
@ -650,13 +702,50 @@ 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;
|
||||
}
|
||||
|
|
@ -675,7 +764,27 @@ 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.
|
||||
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
|
||||
//
|
||||
// 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
|
||||
&& sp.CheckCellId != 0 && oi.StepDown)
|
||||
{
|
||||
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
|
||||
|
|
@ -733,7 +842,23 @@ public sealed class Transition
|
|||
// we are missing precipice context, a steep contact plane, or
|
||||
// merely the EdgeSlide flag.
|
||||
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
|
||||
return EdgeSlideAfterStepDownFailed(engine, 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 TransitionState.OK;
|
||||
|
|
@ -753,10 +878,41 @@ 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;
|
||||
|
|
@ -767,6 +923,7 @@ 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;
|
||||
|
|
@ -774,8 +931,37 @@ 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);
|
||||
|
|
@ -783,6 +969,7 @@ public sealed class Transition
|
|||
|
||||
if (ci.ContactPlaneValid)
|
||||
{
|
||||
DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal);
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
|
|
@ -802,6 +989,9 @@ public sealed class Transition
|
|||
ci.ContactPlaneIsWater = false;
|
||||
sp.RestoreCheckPos();
|
||||
|
||||
if (!sp.HasWalkablePolygon)
|
||||
sp.RestoreLastWalkable();
|
||||
|
||||
if (sp.HasWalkablePolygon)
|
||||
return sp.PrecipiceSlide(this);
|
||||
|
||||
|
|
@ -814,20 +1004,53 @@ public sealed class Transition
|
|||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
||||
if (!ci.LastKnownContactPlaneValid)
|
||||
return TransitionState.OK;
|
||||
// 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";
|
||||
}
|
||||
|
||||
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal);
|
||||
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
|
||||
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)
|
||||
{
|
||||
|
|
@ -853,7 +1076,74 @@ 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} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
|
||||
$"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}"));
|
||||
}
|
||||
|
||||
private static string Fmt(Vector3 value) =>
|
||||
|
|
@ -1343,7 +1633,18 @@ public sealed class Transition
|
|||
else if (ci.LastKnownContactPlaneValid)
|
||||
contactPlane = ci.LastKnownContactPlane;
|
||||
else
|
||||
contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f);
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Crease direction = cross(collisionNormal, contactPlane.Normal).
|
||||
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
|
||||
|
|
@ -1850,12 +2151,44 @@ 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.
|
||||
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;
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
else
|
||||
oi.State &= ~ObjectInfoState.OnWalkable;
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -506,6 +506,85 @@ 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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -261,6 +261,52 @@ 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()
|
||||
{
|
||||
|
|
|
|||
119
tools/pdb-extract/check_exe_pdb.py
Normal file
119
tools/pdb-extract/check_exe_pdb.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""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()
|
||||
98
tools/pdb-extract/dump_pdb_info.py
Normal file
98
tools/pdb-extract/dump_pdb_info.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue