Compare commits
No commits in common. "77b59d89e22d464d7285a2b0cb823caf110f3da6" and "1abb699c6805ab00f7ca515cdedac2912310fca2" have entirely different histories.
77b59d89e2
...
1abb699c68
20 changed files with 64 additions and 2423 deletions
23
.gitignore
vendored
23
.gitignore
vendored
|
|
@ -41,26 +41,3 @@ tmp/
|
|||
|
||||
# Git worktrees for isolated feature work
|
||||
.worktrees/
|
||||
|
||||
# Per-session retail-debugger scratch — cdb scripts, logs, analysis helpers.
|
||||
# The committed reference workflow lives in CLAUDE.md "Retail debugger toolchain";
|
||||
# session-specific traces should not pollute the repo.
|
||||
*.cdb
|
||||
launch_*.log
|
||||
launch_*.err
|
||||
launch_*.ps1
|
||||
launch[0-9]*.log
|
||||
analyze_*.ps1
|
||||
peek_*.ps1
|
||||
run_cdb_*.ps1
|
||||
find_cdb.ps1
|
||||
find_acclient.ps1
|
||||
kill_cdb.ps1
|
||||
append_memory.ps1
|
||||
sky_*.log
|
||||
smoke_test*
|
||||
steep_roof_trace*
|
||||
substep_trace*
|
||||
sg_built.txt
|
||||
# Stray bash-mangled path artifacts from PowerShell-via-bash escaping
|
||||
C[-]*
|
||||
|
|
|
|||
177
CLAUDE.md
177
CLAUDE.md
|
|
@ -112,51 +112,6 @@ a phase just landed, and move to the next todo item.
|
|||
always yes — keep going.** The single exception is visual verification;
|
||||
otherwise, act.
|
||||
|
||||
## Communication style
|
||||
|
||||
The user is a strong systems / C# / network programmer but **less
|
||||
practiced at 3D math, physics, graphics, and animation**. They want
|
||||
to learn — they're not asking for dumbed-down content, but for
|
||||
explanations that build understanding alongside the work.
|
||||
|
||||
When discussing 3D / physics / graphics / animation / dat-format /
|
||||
protocol-internals topics:
|
||||
|
||||
- **Name the concept in plain language first**, then introduce the
|
||||
term of art. "The angle of a slope (we call its straight-up
|
||||
component `Normal.Z`)" rather than dropping `normal.Z = cos θ`
|
||||
with no anchor.
|
||||
- **Give units**: degrees, meters, cm — NOT raw floats. "FloorZ ≈
|
||||
0.66 means slopes up to about 49° are walkable" rather than
|
||||
"FloorZ = 0.66417414f". Floats are for the code; English is for
|
||||
the conversation.
|
||||
- **Use analogies for spatial concepts** when they fit. A BSP tree
|
||||
is "a way of slicing space into nested rooms"; a contact plane is
|
||||
"the imaginary floor under the player's feet"; a sphere sweep is
|
||||
"rolling a ball forward through space and stopping it on contact";
|
||||
a cross product is "the direction perpendicular to two arrows";
|
||||
a dot product is "how aligned two arrows are (1 = same, 0 =
|
||||
perpendicular, -1 = opposite)".
|
||||
- **Don't pile on multiple new concepts in one paragraph.** If a
|
||||
problem touches step-up AND step-down AND edge-slide AND
|
||||
walkable-polygon tracking, walk through them one at a time, each
|
||||
with what it does and why it exists.
|
||||
- **Show the math when it matters, but explain it.** Don't just
|
||||
drop a formula and move on; tag it with "what this means
|
||||
geometrically".
|
||||
- **Use frame-by-frame walk-throughs** for control-flow-heavy
|
||||
physics: "frame N: player here, lands. Frame N+1: state checks…"
|
||||
beats a function-call trace for understanding what's happening
|
||||
in motion.
|
||||
- **Flag terms of art** the first time they appear in a session,
|
||||
even if they're sprinkled through code comments. "Broadphase",
|
||||
"BSP", "step-up", "ContactPlane", "ValidateWalkable" — they
|
||||
earn their meaning the first time you spell it out.
|
||||
|
||||
The goal is collaborative learning. Don't simplify the content; just
|
||||
make sure every term and number is grounded so the user can keep up
|
||||
and build intuition over time.
|
||||
|
||||
## Development workflow: grep named → decompile → verify → port
|
||||
|
||||
**This is the mandatory workflow for implementing ANY AC-specific behavior.**
|
||||
|
|
@ -164,22 +119,10 @@ The triangle-boundary Z bug cost 5 failed fix attempts from guessing.
|
|||
The animation frame-swap bug cost 4 failed attempts. Every time we
|
||||
checked the decompiled code first, we got it right on the first try.
|
||||
**Now we have named retail symbols too — Step 0 cuts most lookups
|
||||
from 30 minutes to 5 seconds. And as of 2026-04-30, when "what does
|
||||
retail actually DO at runtime?" is the question and decomp alone
|
||||
isn't enough, attach cdb to a live retail client (Step -1).**
|
||||
from 30 minutes to 5 seconds.**
|
||||
|
||||
### For each new feature or bug fix:
|
||||
|
||||
-1. **ATTACH cdb TO RETAIL (when behavior is the question, not code).**
|
||||
For "what does retail actually DO frame-by-frame?" questions —
|
||||
wedges, weird animation flicker, geometry-specific bugs, anything
|
||||
where the decomp is correct but it's not clear how it produces the
|
||||
visible behavior — **don't guess; attach the Windows debugger to
|
||||
a live retail client and trace it.** See "Retail debugger toolchain"
|
||||
below for setup. We discovered the steep-roof wedge had a 30Hz
|
||||
physics-tick cause this way; would have taken weeks of guessing
|
||||
without the trace.
|
||||
|
||||
0. **GREP NAMED FIRST.** Before any decompilation work, search
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` by
|
||||
`class::method` name. 99.6% of functions have real names from the
|
||||
|
|
@ -261,124 +204,6 @@ Before marking any phase as done:
|
|||
- [ ] Roadmap updated
|
||||
- [ ] Memory updated if there's a durable lesson
|
||||
|
||||
## Retail debugger toolchain (live runtime trace)
|
||||
|
||||
**When the question is "what does retail actually DO frame-by-frame?"**
|
||||
the decomp alone is often not enough — code paths interact with state
|
||||
(LastKnownContactPlane, transient flags, accumulated counters) in ways
|
||||
that aren't obvious from reading. As of 2026-04-30 we have a working
|
||||
toolchain to attach Windows' console debugger (cdb.exe) to a live
|
||||
retail acclient.exe with full PDB symbols and capture state at any
|
||||
breakpoint. **Use this when guessing has failed twice in a row.**
|
||||
|
||||
### What we have
|
||||
|
||||
- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe`
|
||||
v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`,
|
||||
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs
|
||||
exactly with our `refs/acclient.pdb`.
|
||||
- **Debugger**: `cdb.exe` (console WinDbg) at
|
||||
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
|
||||
Install via Microsoft Store WinDbg (~50 MB). 32-bit version is
|
||||
required for acclient.exe.
|
||||
- **PDB**: `refs/acclient.pdb` (29 MB, Sept 2013 EoR build).
|
||||
18,366 named functions + 5,371 named struct types resolve.
|
||||
- **Symbol verifier**: `tools/pdb-extract/check_exe_pdb.py <exe>`
|
||||
reads any acclient.exe and prints whether it pairs with our PDB
|
||||
(`MATCH` / `MISMATCH (expected GUID = ...)`). Always run this on
|
||||
a candidate binary BEFORE attaching.
|
||||
- **PDB metadata dumper**: `tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb`
|
||||
prints the PDB's expected timestamp + GUID + age. Use to figure
|
||||
out which build to look for if the chain ever breaks.
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Verify the binary matches the PDB:**
|
||||
```bash
|
||||
py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"
|
||||
```
|
||||
Expect: `=== MATCH: this exe pairs with our acclient.pdb ===`
|
||||
|
||||
2. **Have the user launch retail client** and connect to local ACE.
|
||||
Retail must already be in-world before attaching.
|
||||
|
||||
3. **Write a `.cdb` script** that arms breakpoints with non-blocking
|
||||
actions (count + log + `gc`). Pattern:
|
||||
```
|
||||
.logopen <output-path>
|
||||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
|
||||
r $t0 = 0
|
||||
bp acclient!CTransition::transitional_insert "r $t0 = @$t0 + 1; .if (@$t0 % 5000 == 0) { .printf \"...\" }; .if (@$t0 >= 30000) { qd } .else { gc }"
|
||||
bp acclient!OBJECTINFO::kill_velocity "r $t1 = @$t1 + 1; gc"
|
||||
...
|
||||
g
|
||||
```
|
||||
`gc` = "go conditional" (continue without breaking). Auto-detach
|
||||
via `qd` after a hit-count threshold to avoid manual cleanup.
|
||||
|
||||
4. **Launch cdb in the background** via a PowerShell wrapper:
|
||||
```powershell
|
||||
& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" `
|
||||
-pn acclient.exe -cf <script>.cdb *>&1 |
|
||||
Tee-Object -FilePath <log>
|
||||
```
|
||||
|
||||
5. **User reproduces the scenario** in the retail client window
|
||||
(jump on roof, hit wall, etc.). Breakpoints fire, log fills.
|
||||
|
||||
6. **cdb auto-detaches** when the threshold breakpoint fires `qd`.
|
||||
Retail keeps running unaffected. Read the log offline.
|
||||
|
||||
### Known watchouts
|
||||
|
||||
- **PDB function names use snake_case for some classes**
|
||||
(BSPTREE, CTransition, OBJECTINFO, COLLISIONINFO, SPHEREPATH) and
|
||||
**PascalCase for others** (CPhysicsObj). The Binary Ninja decomp
|
||||
shows snake_case for everything; the PDB has Turbine's actual
|
||||
PascalCase for CPhysicsObj. Always look up symbols with `x` first
|
||||
to find the actual name.
|
||||
|
||||
- **`bp acclient!Class::method`** sets a breakpoint by symbol. The
|
||||
cdb command parser splits on `;`, so don't put `;` inside the
|
||||
action string — use newlines or escape carefully.
|
||||
|
||||
- **Symbol path: do NOT use `.sympath srv*<server>;<local>`** — the
|
||||
`;` is a cdb command separator, gets split. Use `.sympath <local>`
|
||||
(no symbol server, just our refs/) since we don't need Microsoft
|
||||
system DLL symbols.
|
||||
|
||||
- **Killing cdb kills the debuggee.** Use `qd` (quit detached) inside
|
||||
a breakpoint action to detach cleanly. `Stop-Process cdb` will
|
||||
take the retail client down with it.
|
||||
|
||||
- **High breakpoint hit rates produce game lag.** Each breakpoint hit
|
||||
traps the process briefly. For frequent functions
|
||||
(transitional_insert at ~10K/sec) the cumulative cost is enough to
|
||||
make retail feel sluggish. Mitigate by setting a tight auto-detach
|
||||
threshold (e.g., 30,000 hits) and/or moving counters to less-frequent
|
||||
functions.
|
||||
|
||||
- **acclient.exe is 32-bit + uses thiscall.** When dumping struct
|
||||
fields in breakpoint actions, `this` is in `ecx`. Use cdb's
|
||||
`dt acclient!ClassName @ecx` for full struct dump.
|
||||
|
||||
### When NOT to use this
|
||||
|
||||
- **Pure code-port questions** — the decomp at `docs/research/named-retail/`
|
||||
has the answer. Don't waste time on cdb if `grep` is enough.
|
||||
- **Visual / rendering bugs** — debugger doesn't help with shaders or
|
||||
framebuffers; use RenderDoc or similar.
|
||||
- **Network protocol questions** — `holtburger` references + ACE source
|
||||
+ Wireshark are the right tools, not cdb.
|
||||
|
||||
This toolchain was used to settle the L.5 steep-roof investigation:
|
||||
30Hz physics tick (vs our 60Hz), `kill_velocity` gating,
|
||||
`set_collide` rate per minute. See commit history around 2026-04-30
|
||||
for the trace data and the decisions it drove.
|
||||
|
||||
## Subagent policy
|
||||
|
||||
Subagents are the primary tool for saving parent-context and keeping one
|
||||
|
|
|
|||
345
docs/ISSUES.md
345
docs/ISSUES.md
|
|
@ -46,164 +46,6 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Active issues
|
||||
|
||||
## #38 — Chase camera + player feel "30 fps" since L.5 physics-tick gate
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (gameplay-feel regression; not a correctness bug)
|
||||
**Filed:** 2026-05-01
|
||||
**Component:** rendering / physics / camera
|
||||
|
||||
**Description:** User reports that running around in third-person /
|
||||
chase camera feels less smooth than it did before the L.5 physics-tick
|
||||
work. FPS counter still reads 60+, but the *motion* of the player
|
||||
character + camera looks like it's updating at ~30 fps.
|
||||
|
||||
**Root cause / status:**
|
||||
|
||||
Almost certainly the L.5 `_physicsAccum` gate in
|
||||
`PlayerMovementController.cs` (lines ~448-456). Retail integrates
|
||||
physics at 30 Hz (`MinQuantum = 1/30 s`); we ported that faithfully so
|
||||
collision behavior matches. Side effect: `_body.Position` only updates
|
||||
on physics ticks, i.e. every 33 ms. Render runs at 60+ Hz but the
|
||||
chase camera follows `_body.Position` directly — so the *visible*
|
||||
position changes in 33 ms steps, even though we render at 60+ FPS.
|
||||
First-person is less affected because the world rotates with Yaw (which
|
||||
*does* update every render frame); third-person is hit hardest because
|
||||
the character itself is the moving thing.
|
||||
|
||||
Retail in 2013 didn't see this because render was also ~30 fps —
|
||||
render rate ≈ physics rate. Our 60+ Hz render exposes the gap.
|
||||
|
||||
Discussion + fix options at the end of `docs/research/2026-05-01-retail-motion-trace/findings.md`
|
||||
("Other things still don't have…" → camera smoothness discussion in
|
||||
chat, not yet captured in the doc — TODO migrate the discussion in).
|
||||
|
||||
Recommended fix: **render-time interpolation between physics ticks**
|
||||
(standard fixed-timestep + interpolated rendering pattern from Quake /
|
||||
Source / Unreal). Snapshot `_prevPhysicsPos` and `_currPhysicsPos` at
|
||||
each tick; render player + camera target at
|
||||
`Lerp(_prev, _curr, _physicsAccum / PhysicsTick)`. Cost: ~33 ms visual
|
||||
latency between input and what you see (matches retail's perceived
|
||||
latency anyway). Network outbound stays on the discrete tick value —
|
||||
no wire change.
|
||||
|
||||
Quick confirmation test before any code change: temporarily set
|
||||
`PhysicsTick` to `1.0/60.0` and see if chase camera feels smooth again.
|
||||
If yes, gate is confirmed cause. (Don't ship that — it'd undo the L.5
|
||||
collision fixes.)
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/AcDream.App/Input/PlayerMovementController.cs:172` — `PhysicsTick` constant
|
||||
- `src/AcDream.App/Input/PlayerMovementController.cs:448-456` — `_physicsAccum` gate
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — wherever player render position + chase camera read `_body.Position`
|
||||
|
||||
**Research:**
|
||||
|
||||
- L.5 background: `memory/project_retail_debugger.md` (the 30 Hz
|
||||
MinQuantum gate, the cdb trace evidence)
|
||||
- Discussed during 2026-05-01 motion-trace work
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Chase-camera run-around at 60+ FPS feels as smooth as render rate
|
||||
suggests (no perceptual stepping)
|
||||
- Network outbound (MoveToState / AutonomousPosition cadence + values)
|
||||
unchanged from current behavior
|
||||
- Collision behavior unchanged (the L.5 wedge / steep-roof scenarios
|
||||
still resolve correctly)
|
||||
- Observer view from a parallel retail client unchanged
|
||||
|
||||
## #37 — Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (cosmetic; doesn't affect gameplay)
|
||||
**Filed:** 2026-05-01
|
||||
**Component:** rendering / clothing / textures
|
||||
|
||||
**Description:** Every humanoid character (player + NPCs) wearing a coat
|
||||
shows a visible skin-colored region at the top of the coat where retail
|
||||
shows continuous coat fabric. From the back view: hair → skin stub →
|
||||
coat top. In retail: hair → coat collar (no exposed skin). This was
|
||||
originally reported as "head/neck protruding forward" — the apparent
|
||||
forward shift is an optical illusion caused by the missing coat collar.
|
||||
|
||||
**Investigation 2026-05-01 (~3 hr session, conclusively ruled out
|
||||
many hypotheses):**
|
||||
|
||||
What we ruled out:
|
||||
|
||||
- **Animation source.** `ACDREAM_USE_PLACEMENT_BASE=1` (force chars to
|
||||
`Setup.PlacementFrames[Resting]` instead of `Animation.PartFrames[0]`)
|
||||
→ stub still visible.
|
||||
- **Backface culling / mesh winding.** `ACDREAM_NO_CULL=1` (disable
|
||||
`glCullFace` entirely) → stub still visible.
|
||||
- **Palette overlay (SubPalettes).** `ACDREAM_NO_PALETTE_OVERLAY=1`
|
||||
(skip `ComposePalette`) → stub still visible (other colors broke
|
||||
as expected — confirms overlay was firing). Bug is NOT a body-skin
|
||||
SubPalette being mis-applied to coat fabric.
|
||||
- **Bug source = part 16 (head).** `ACDREAM_HIDE_PART=16` → head goes
|
||||
away, stub remains UNCHANGED (clean coat top with same shape).
|
||||
Stub is NOT from head GfxObj polygons.
|
||||
- **Per-part placement frame Origin.** `ACDREAM_NUDGE_Y=-0.1` confirmed
|
||||
`+Y = forward` in body-local; head Origin (0, 0.013, 1.587) places
|
||||
head correctly relative to spine. Math checks out.
|
||||
|
||||
What we confirmed (data is correct):
|
||||
|
||||
- Player Setup `0x02000001` (Aluvian Male), 34 parts.
|
||||
- Server (ACE) sends `animParts=34 texChanges=12 subPalettes=10`.
|
||||
- Part 9 (upper torso/coat) has gfx `0x0100120D` after AnimPartChange.
|
||||
- Part 9 has 2 surfaces, BOTH covered by 2 TextureChanges
|
||||
(`oldTex=0x050003D5→0x05001AFE`, `oldTex=0x050003D4→0x05001AFC`).
|
||||
- Stub IS from part 9: `ACDREAM_HIDE_PART=9` → entire torso (including
|
||||
stub region) disappears.
|
||||
- Per-part composition formula (`Scale × Rotation × Translation`)
|
||||
matches ACME's `StaticObjectManager.cs:256-258` and retail decomp's
|
||||
`Frame::combine` at `0x00518FD0`.
|
||||
|
||||
**Remaining hypothesis space (untested):**
|
||||
|
||||
1. **Texture decode produces skin pixels** for `0x05001AFE/0x05001AFC`
|
||||
where ACME / retail produces coat pixels. Compare our SurfaceDecoder
|
||||
against ACME's `TextureHelpers.cs` for INDEX16 / palette-indexed
|
||||
chains.
|
||||
2. **Polygon-to-surface mapping off-by-one.** Specific polygons of
|
||||
part 9 reference an unintended surface. Add a dump: for each polygon
|
||||
in gfx 0x0100120D, print `PosSurface` index + the resolved Surface id.
|
||||
3. **Multi-layer texture composition retail does and we skip.** AC's
|
||||
"ApplyCloth" or similar layered texture step. Grep
|
||||
`acclient_2013_pseudo_c.txt` for `BlendBaseLayer`, `LayerSurfaces`,
|
||||
any composition method that combines multiple textures into one.
|
||||
4. **UV mapping bug.** Part 9's polygon UVs map to a skin region of
|
||||
the coat texture. Dump per-vertex UV vs vertex Z; if a high-Z vertex
|
||||
has UV.v near a skin region, that's the source.
|
||||
|
||||
**Files (diagnostic env vars committed for next-session reuse):**
|
||||
|
||||
- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
|
||||
— `ACDREAM_NO_CULL` env var
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — `ACDREAM_HIDE_PART=N`
|
||||
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
|
||||
AnimPartChanges + TextureChanges + per-part Surface chain coverage.
|
||||
- `src/AcDream.App/Rendering/TextureCache.cs:159-204` — `DecodeFromDats`
|
||||
is the texture decode entry. Compare against
|
||||
`references/WorldBuilder-ACME-Edition/.../TextureHelpers.cs`.
|
||||
|
||||
**Reproduction:**
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_LIVE = "1"; $env:ACDREAM_DEVTOOLS = "1"
|
||||
# normal launch — visible from chase camera looking at +Acdream's back
|
||||
```
|
||||
|
||||
Stub is visible on +Acdream and on every NPC humanoid (Pathwarden,
|
||||
Town Crier, Shopkeeper Renald, etc.).
|
||||
|
||||
**Acceptance:** Side-by-side retail + acdream rendering of +Acdream
|
||||
shows coat extending up to chin level on both. No exposed skin
|
||||
between hair and coat.
|
||||
|
||||
## #L.1 — Hotbar UI panel
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
@ -350,48 +192,12 @@ hard-blocks or accepts too much in several of these cases.
|
|||
`step_up_slide` now feels acceptable in live testing. Local/remote movement
|
||||
passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
|
||||
preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
|
||||
before `SPHEREPATH::precipice_slide`; edge-slide `Slid` / `Adjusted` results
|
||||
now feed the `TransitionalInsert` retry loop instead of being reverted by outer
|
||||
validation, and a synthetic diagonal terrain-boundary test covers tangent
|
||||
motion. `ACDREAM_DUMP_EDGE_SLIDE=1` now reports whether a failed step-down had
|
||||
polygon context.
|
||||
|
||||
**L.4/L.5 update 2026-04-30:** A retail debugger trace (cdb attached to
|
||||
v11.4186 acclient.exe — see #35) confirmed that retail does NOT wedge
|
||||
on the steep-roof scenario that produces the wedge in our acdream port.
|
||||
Three concrete findings:
|
||||
1. Retail's `OBJECTINFO::kill_velocity` rarely fires in normal play —
|
||||
gated on `last_known_contact_plane_valid`, which our L.2.4 proximity
|
||||
guard tends to clear before steep-poly hits land. Retail trace: 0
|
||||
kill_velocity hits across 40,960 update_object calls. Our Phase 3
|
||||
reset path now matches retail's gate (only kills when valid).
|
||||
2. Retail integrates physics at 30Hz (`MinQuantum = 1/30 s`); render is
|
||||
60+ Hz. UpdatePhysicsInternal/update_object ratio = 0.61. We
|
||||
ported this gate as L.5 in `PlayerMovementController` via
|
||||
`_physicsAccum`. Render still runs at 60+ Hz; only the physics
|
||||
integration step is 30Hz.
|
||||
3. The remaining wedge cause — body's pre-position drifts to the
|
||||
polygon's tangent and gravity's tangent component into surface
|
||||
produces a stable retain-collide-revert loop — is a downstream
|
||||
consequence of retail's grounded-on-steep escape chain
|
||||
(`step_sphere_up` → `step_up_slide` → `cliff_slide`) being
|
||||
incompletely ported. Live test confirmed retail-strict Path 6
|
||||
produces "lands on roof in falling animation, can't slide off"
|
||||
half-state because that chain doesn't produce smooth descent.
|
||||
|
||||
**Pragmatic ship-state:** BSPQuery Path 6 keeps the L.4 slide-tangent
|
||||
deviation (project-along-steep-face-and-return-Slid) for steep-poly
|
||||
airborne hits. It produces user-acceptable "slide off the roof"
|
||||
behavior at the cost of departing from retail's Path 6 → SetCollide →
|
||||
Path 4 → Phase 3 reset chain. Retail-strict requires the
|
||||
step_up_slide / cliff_slide audit below; until that lands, slide-tangent
|
||||
is the right deviation.
|
||||
|
||||
Remaining gaps: real-DAT building-edge fixtures, fuller `cliff_slide`
|
||||
coverage, `NegPolyHit` dispatch, and the retail-strict
|
||||
step_up_slide / cliff_slide audit (filed for follow-up). Named retail
|
||||
anchors include `CTransition::edge_slide`, `CTransition::cliff_slide`,
|
||||
`SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`.
|
||||
before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports
|
||||
whether a failed step-down had polygon context. Remaining gaps: real-DAT
|
||||
building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit`
|
||||
dispatch. Named retail anchors include `CTransition::edge_slide`,
|
||||
`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
|
||||
`SPHEREPATH::step_up_slide`.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
|
||||
`src/AcDream.Core/Physics/BSPQuery.cs`,
|
||||
|
|
@ -405,139 +211,6 @@ cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case.
|
|||
|
||||
---
|
||||
|
||||
## #35 — [DONE 2026-04-30] Retail debugger toolchain (cdb + PDB GUID matching)
|
||||
|
||||
**Status:** DONE
|
||||
**Severity:** N/A (infrastructure)
|
||||
**Filed + closed:** 2026-04-30
|
||||
**Component:** tooling / research
|
||||
|
||||
**Description:** When the question is "what does retail actually DO at
|
||||
runtime?" — wedges, animation flicker, geometry-specific bugs where the
|
||||
decomp is correct but the visible behavior is mysterious — there was no
|
||||
way to attach a debugger to a live retail acclient.exe and trace it.
|
||||
This issue tracks the toolchain that closed that gap.
|
||||
|
||||
**What shipped:**
|
||||
- **`tools/pdb-extract/check_exe_pdb.py`** — reads any PE's CodeView entry
|
||||
and reports `MATCH` / `MISMATCH (expected GUID = …)` against our
|
||||
`refs/acclient.pdb`. Always run before attaching cdb.
|
||||
- **`tools/pdb-extract/dump_pdb_info.py`** — dumps a PDB's expected
|
||||
build timestamp + GUID + age. Used to figure out which acclient.exe
|
||||
build pairs with our PDB (answer: v11.4186, Sept 2013 EoR).
|
||||
- **CLAUDE.md "Retail debugger toolchain" section** — full workflow:
|
||||
cdb path, sample `.cdb` script, PowerShell wrapper pattern, watchouts
|
||||
(PDB name conventions, `;` parsing, kill-target-on-detach behavior,
|
||||
high-hit-rate lag).
|
||||
- **Step `-1` added to the development workflow** — "ATTACH cdb TO
|
||||
RETAIL (when behavior is the question, not code)". Tells future
|
||||
sessions: when guessing has failed twice in a row, don't keep guessing.
|
||||
|
||||
**Discoveries this toolchain enabled (closed in same session):**
|
||||
- Retail integrates physics at 30Hz (`UpdatePhysicsInternal/update_object`
|
||||
ratio = 0.61). Drove the L.5 fix in PlayerMovementController.
|
||||
- `OBJECTINFO::kill_velocity` rarely fires in normal play (gated on
|
||||
last_known_contact_plane_valid). Our acdream port now matches.
|
||||
- Retail does NOT wedge on the steep-roof scenario. Confirmed our L.4
|
||||
slide-tangent deviation in Path 6 is necessary until the retail
|
||||
step_up_slide / cliff_slide chain audit lands.
|
||||
|
||||
**Files:** `tools/pdb-extract/check_exe_pdb.py`,
|
||||
`tools/pdb-extract/dump_pdb_info.py`, `CLAUDE.md`,
|
||||
`memory/project_retail_debugger.md`.
|
||||
|
||||
**Acceptance:** Future sessions can attach cdb to a live retail client
|
||||
in under 5 minutes by following the CLAUDE.md workflow.
|
||||
|
||||
---
|
||||
|
||||
## #36 — Sky-PES dispatch port (consolidates #2 / #28 / #29 visual gaps)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (aesthetic feature-parity, but addresses a cluster of bugs)
|
||||
**Filed:** 2026-04-30
|
||||
**Component:** sky / weather / particles
|
||||
|
||||
**Description:** Three open sky bugs (#2 lightning, #28 aurora, #29 cloud
|
||||
density) all trace back to the same missing infrastructure: retail's
|
||||
sky-PES (Particle Effect Script) dispatch chain. We have it now from a
|
||||
2026-04-30 cdb live trace.
|
||||
|
||||
**What retail does (live trace evidence):**
|
||||
|
||||
```
|
||||
Trace over 24,576 GameSky::Draw frames:
|
||||
GameSky::Draw = 24,576 (60 Hz render rate)
|
||||
GameSky::UseTime = 12,288 (30 Hz — half rate, MinQuantum)
|
||||
GameSky::CreateDeletePhysicsObjects = 12,288 (also 30 Hz)
|
||||
CPhysicsObj::CallPES = 372 (~150/min average)
|
||||
CallPESHook::Execute = 372 (1:1 with CallPES)
|
||||
CreateParticleHook::Execute = 62 (15 at cell load + 47 burst at transition)
|
||||
CPhysicsObj::create_particle_emitter = 62 (matches CreateParticleHook)
|
||||
```
|
||||
|
||||
**Three findings:**
|
||||
1. Retail has **persistent particle emitters** on celestial / sky objects.
|
||||
Created at cell load (15 initial) and dynamically as conditions change
|
||||
(the trace caught a +47 burst on a region/weather/time transition).
|
||||
2. The PES script-hook system (`CallPESHook::Execute` →
|
||||
`CPhysicsObj::CallPES`) drives those emitters periodically, ~150
|
||||
times per minute on average.
|
||||
3. Earlier research said "GameSky doesn't read pes_id" — correct in
|
||||
scope, but missed that the dispatch chain runs through the script-
|
||||
hook system, not from inside GameSky directly. Cell/region/weather
|
||||
handlers schedule PES script hooks; those hooks call into CallPES.
|
||||
|
||||
**Decomp anchors:**
|
||||
- `CallPESHook::Execute` @ `0x00526e20` — script-hook action that fires CallPES
|
||||
- `CreateParticleHook::Execute` @ `0x00526ec0` — particle-creation hook
|
||||
- `CPhysicsObj::CallPES` @ `0x00511af0`
|
||||
- `CPhysicsObj::create_particle_emitter` @ `0x0050f360`
|
||||
- `GameSky::CreateDeletePhysicsObjects` @ `0x005073c0`
|
||||
- `LongNIHash<ParticleEmitter>` instance — emitter registry
|
||||
- `CelestialPosition.pes_id` @ struct offset +0x004 — populated by
|
||||
`SkyDesc::GetSky` but consumed downstream of `GameSky` (via the
|
||||
hook system, not GameSky itself)
|
||||
|
||||
**Implementation outline:**
|
||||
1. Decomp dive: read `CallPESHook::Execute`, `CreateParticleHook::Execute`,
|
||||
`CPhysicsObj::CallPES`, and `GameSky::CreateDeletePhysicsObjects`
|
||||
(and any cell/region weather handlers that spawn the dynamic 47).
|
||||
2. Identify what triggers `CreateParticleHook` for sky objects — is it
|
||||
inside `CreateDeletePhysicsObjects`, the region/weather change handler,
|
||||
or somewhere else?
|
||||
3. Port the persistent-emitter creation path: when a cell loads or
|
||||
weather/time changes, instantiate the appropriate ParticleEmitters
|
||||
on celestial objects.
|
||||
4. Port the PES timeline driver — periodic dispatch from a script
|
||||
timeline into our equivalent `CallPES`.
|
||||
5. Port the actual PES script execution (rate of emission, particle
|
||||
parameters, etc.) into our particle system.
|
||||
6. Live verify with cdb during specific weather windows: aurora at dusk
|
||||
on Rainy DayGroup, lightning during storm.
|
||||
|
||||
**Files** (likely):
|
||||
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — emitter wiring
|
||||
- `src/AcDream.Core/World/SkyDescLoader.cs` — already parses pes_id
|
||||
- `src/AcDream.Core/Particles/*` — particle system foundation
|
||||
- `src/AcDream.App/Rendering/ParticleRenderer.cs` — visual layer
|
||||
|
||||
**Live-trace verification plan (next cdb session):** Reattach to retail
|
||||
during a specific aurora moment, log `this` pointer + `pes_id` arg on
|
||||
every `CallPES` invocation, log the GfxObj being attached on every
|
||||
`create_particle_emitter`. That tells us EXACTLY which celestial
|
||||
objects retail PES-drives and with which IDs.
|
||||
|
||||
**Acceptance:** During the same in-game time/weather where retail shows
|
||||
aurora-style light play (Rainy DayGroup, dusk/dawn windows), acdream
|
||||
shows comparable colored sky effects. Cloud sheets look as dense /
|
||||
purple as retail. Lightning flashes appear during storm windows.
|
||||
|
||||
**Closes-when-done:** #28, #29, partially #2 (lightning may need
|
||||
additional flash-shader work).
|
||||
|
||||
---
|
||||
|
||||
## #33 — Live entity collision shape collapses to one cylinder
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
@ -592,8 +265,6 @@ one live creature case no longer use the single-cylinder fallback.
|
|||
|
||||
**Acceptance:** During a Rainy DayGroup's storm window, visible flashes appear in the sky at the dat-scripted moments, the fragment-shader flash bump briefly brightens the scene, and (later, once thunder audio is wired) a thunder clap plays with a short propagation delay.
|
||||
|
||||
**See also #36** (Sky-PES dispatch port) — the lightning visuals likely route through the same PES-hook chain that drives aurora and cloud-density. Most of #2's storm-flash visuals will be unblocked by the #36 port.
|
||||
|
||||
---
|
||||
|
||||
## #3 — Client clock drifts from retail after ~10 minutes (periodic TimeSync missing)
|
||||
|
|
@ -717,8 +388,6 @@ acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetup
|
|||
|
||||
**Acceptance:** When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time.
|
||||
|
||||
**See #36 (filed 2026-04-30)** — a live cdb trace confirmed retail's aurora rendering uses the script-hook PES dispatch chain (`CallPESHook::Execute` → `CPhysicsObj::CallPES`) on persistent particle emitters, with a cell-load population (15 initial emitters) plus dynamic spawning on region/weather/time transitions (caught a +47 burst). Implementation work consolidated under #36.
|
||||
|
||||
---
|
||||
|
||||
## #29 — Cloud surface 0x08000023 still appears thinner than retail despite blend-mode + Setup fixes
|
||||
|
|
@ -746,8 +415,6 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
|
|||
|
||||
**Acceptance:** Cloud sheets look as dense/purple as retail in dual-client side-by-side. May require #28 (PES) to land first.
|
||||
|
||||
**See #36 (filed 2026-04-30)** — confirmed via live cdb trace: retail's cloud density comes from the same PES-driven particle-emitter chain as aurora. Implementation consolidated there.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning.
|
||||
**Status:** Living document. Updated 2026-04-29 for Phase L.2 movement/collision conformance planning.
|
||||
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
||||
|
||||
---
|
||||
|
|
@ -403,101 +403,6 @@ diagnostic scaffolding, not yet the final collision system.
|
|||
|
||||
---
|
||||
|
||||
### Phase M — Network Stack Conformance
|
||||
|
||||
**Status:** PLANNED.
|
||||
|
||||
**Goal:** replace the current happy-path `WorldSession` networking shape with a
|
||||
proper AC client network stack that reaches functional parity with
|
||||
`references/holtburger/` while preserving acdream's stricter packet checksum
|
||||
verification and live ACE compatibility. This phase owns packet reliability,
|
||||
ordered delivery, retransmission, ACK piggybacking, echo/keepalive, typed
|
||||
message/action routing, diagnostics, and the migration of low-level network
|
||||
responsibilities out of the render tick.
|
||||
|
||||
**Why now:** The Phase 4/A.3 stack is good enough for local ACE smoke testing:
|
||||
login, ISAAC, checksums, per-packet ACKs, fragments, movement, chat, combat,
|
||||
and object updates all work. It is not yet a complete client network runtime.
|
||||
Compared with holtburger it lacks ordered S2C delivery, retransmit request
|
||||
handling, outbound packet caching/retransmission, ACK piggybacking,
|
||||
EchoRequest/EchoResponse handling, runtime ping/timeout policy, and a typed
|
||||
protocol/action layer. These gaps will become expensive as movement, dungeons,
|
||||
inventory, combat, and plugins depend on stable packet semantics.
|
||||
|
||||
**Plan of record:** create
|
||||
`docs/superpowers/specs/2026-05-02-network-stack-conformance.md` before
|
||||
implementation starts. Treat holtburger as the client-behavior oracle for this
|
||||
phase; cross-check wire details against named retail, ACE, Chorizite, and AC2D
|
||||
before porting.
|
||||
|
||||
**Sub-lanes:**
|
||||
- **M.1 — Audit & parity map.** Produce a source-by-source comparison of
|
||||
acdream `AcDream.Core.Net` and holtburger `holtburger-session`,
|
||||
`holtburger-protocol`, and `holtburger-core` networking code. Inventory each
|
||||
packet flag, optional header, session transition, control packet, fragment
|
||||
path, game message, and game action. Mark each as `parity`, `partial`,
|
||||
`missing`, or `intentional divergence`.
|
||||
- **M.2 — Layer extraction.** Split the low-level stack under `WorldSession`
|
||||
into testable components: `INetTransport`, `PacketCodec`,
|
||||
`ReliablePacketSession`, `FragmentSession`, `GameMessageSession`, and the
|
||||
high-level `WorldSession` behavior layer. Preserve existing public events and
|
||||
live-client call sites during the migration.
|
||||
- **M.3 — Reliability core.** Port holtburger-style sequence tracking:
|
||||
last-delivered server sequence, duplicate/old-packet suppression,
|
||||
out-of-order buffering, missing-sequence detection, throttled
|
||||
`RequestRetransmit`, outbound packet cache, server-requested C2S retransmit,
|
||||
`RejectRetransmit` handling, and retransmission checksum recomputation.
|
||||
- **M.4 — ACK and control-packet policy.** Replace standalone-only ACKs with
|
||||
queued ACK state, ACK piggybacking on normal outbound packets, standalone ACK
|
||||
flush when idle, and clean handling for ACK-only packets. Add EchoRequest /
|
||||
EchoResponse and idle ping/timeout behavior matching holtburger unless named
|
||||
retail proves a different cadence.
|
||||
- **M.5 — Fragment and payload completeness.** Keep inbound multi-fragment
|
||||
assembly, add TTL/eviction diagnostics for orphaned partials, implement
|
||||
outbound multi-fragment splitting when payloads exceed the current single
|
||||
fragment limit, and verify fragment id/sequence/queue behavior against
|
||||
holtburger, ACE, and retail evidence.
|
||||
- **M.6 — Typed protocol surface.** Introduce typed `GameMessage` and
|
||||
`GameAction` routing modeled on holtburger. Migrate current builders and
|
||||
parsers first: login complete, DDD, movement, chat/TurbineChat, combat,
|
||||
spellcast, item/appraise, object/update/motion/position, and teleport.
|
||||
Unknown opcodes must remain observable and non-fatal.
|
||||
- **M.7 — Runtime loop and diagnostics.** Move decode/order/reassembly/control
|
||||
handling out of the render tick into a dedicated network runtime that emits
|
||||
clean session events through channels. Add byte counters, packet trace hooks,
|
||||
optional capture/replay fixtures, idle/disconnect state, and dev-panel
|
||||
diagnostics suitable for packet debugging.
|
||||
- **M.8 — Conformance tests and live validation.** Add deterministic unit tests
|
||||
for checksum, ISAAC key consumption, optional headers, ordering, retransmit,
|
||||
ACK piggybacking, echo, fragments, typed actions, and login flow. Add
|
||||
replay/capture tests from holtburger-style fixtures where possible. Finish
|
||||
with `dotnet build`, `dotnet test`, local ACE login, chat, movement, combat
|
||||
action smoke test, reconnect test, and user visual sign-off where networking
|
||||
affects the running client.
|
||||
|
||||
**Non-goals:**
|
||||
- Do not reimplement ACE server behavior.
|
||||
- Do not replace acdream's stricter inbound checksum verification unless named
|
||||
retail proves it is wrong.
|
||||
- Do not rewrite renderer/gameplay systems as part of this phase; migrate
|
||||
network call sites through compatibility adapters first.
|
||||
- Do not remove unknown-message visibility. Plugins and devtools need packet
|
||||
trace surfaces even when parsers are incomplete.
|
||||
|
||||
**Acceptance:**
|
||||
- acdream has a layered, testable network stack rather than one monolithic
|
||||
`WorldSession`.
|
||||
- Every holtburger session capability has an acdream equivalent, an explicit
|
||||
test, or a documented intentional divergence with retail/ACE evidence.
|
||||
- Packet ordering, retransmit, outbound cache, ACK piggybacking, echo/keepalive,
|
||||
fragment assembly/splitting, and typed message/action routing are covered by
|
||||
tests.
|
||||
- Live ACE loop succeeds: login, enter world, movement, chat, combat action,
|
||||
teleport/reconnect, and clean logout.
|
||||
- `dotnet build` and `dotnet test` are green for every implementation slice.
|
||||
|
||||
---
|
||||
|
||||
### Phase J — Long-tail (deferred / low-priority)
|
||||
|
||||
Not detailed here; each gets its own brainstorm when it becomes relevant.
|
||||
|
|
@ -581,12 +486,6 @@ port in any phase — no separate listing here.
|
|||
| Can't walk past the loaded 3×3 window | **A.1 FIXED** ✓ (5×5 default, `ACDREAM_STREAM_RADIUS` to tune) |
|
||||
| Frame hitch crossing landblock boundary | **Phase A.3** (synchronous loader for now; async returns when DatCollection is thread-safe) |
|
||||
| Walking around doesn't move me on the server | **Phase B.2/B.3 FIXED** ✓ for coarse server movement; fine retail collision parity is **Phase L.2** |
|
||||
| Packet loss / out-of-order UDP causes stale or missing world updates | **Phase M.3** |
|
||||
| Server asks for retransmit and client doesn't resend cached packets | **Phase M.3** |
|
||||
| ACKs are standalone-only instead of piggybacked like a full client | **Phase M.4** |
|
||||
| Echo / idle keepalive / reconnect behavior is incomplete | **Phase M.4 + M.7** |
|
||||
| Large outbound game messages exceed the current single-fragment path | **Phase M.5** |
|
||||
| Network protocol coverage is spread across ad-hoc builders/parsers | **Phase M.6** |
|
||||
| Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** |
|
||||
| Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** |
|
||||
| Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** |
|
||||
|
|
|
|||
|
|
@ -122,11 +122,8 @@ Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels
|
|||
acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and
|
||||
BSP step-down/find-walkable now preserve walkable polygon vertices; failed
|
||||
step-down edge cases perform the retail back-probe before
|
||||
`SPHEREPATH::precipice_slide`; precipice slide results now re-enter the
|
||||
`TransitionalInsert` retry loop so tangent edge motion is preserved instead of
|
||||
being reverted by outer validation. Remaining L.2c work is live visual
|
||||
confirmation at real building/roof edges, real-DAT building-edge fixtures,
|
||||
fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
|
||||
`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge
|
||||
fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
|
||||
|
||||
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects
|
||||
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
# Plan — Sky-PES dispatch port (Issue #36)
|
||||
|
||||
**Filed:** 2026-04-30 from a live cdb trace of retail acclient.exe.
|
||||
**Owner:** next session.
|
||||
**Closes:** #28 (aurora), #29 (cloud density), partially #2 (lightning).
|
||||
|
||||
## What we know (from the live trace, 24,576 GameSky::Draw frames)
|
||||
|
||||
Retail's sky-PES dispatch chain runs as follows. All counts are from
|
||||
the cdb trace summarized in `memory/project_retail_debugger.md`:
|
||||
|
||||
```
|
||||
GameSky::Draw = 24,576 (60Hz render rate)
|
||||
GameSky::UseTime = 12,288 (30Hz, MinQuantum gate)
|
||||
GameSky::CreateDeletePhysicsObjects = 12,288 (30Hz)
|
||||
CPhysicsObj::CallPES = 372 (~150/min)
|
||||
CallPESHook::Execute = 372 (1:1 with CallPES)
|
||||
CreateParticleHook::Execute = 62 (15 initial + 47 burst)
|
||||
CPhysicsObj::create_particle_emitter = 62 (matches CreateParticleHook)
|
||||
```
|
||||
|
||||
Three concrete findings:
|
||||
|
||||
1. **Persistent particle emitters on celestial / sky objects.** 15 are
|
||||
created at cell load. More are spawned dynamically on region /
|
||||
weather / time-of-day transitions (the trace caught a +47 burst on
|
||||
one such transition).
|
||||
|
||||
2. **Periodic PES dispatch drives existing emitters.**
|
||||
`CallPESHook::Execute` runs script-scheduled actions which call
|
||||
`CPhysicsObj::CallPES` 1:1. ~150 dispatches/min on average.
|
||||
|
||||
3. **Earlier research said "GameSky doesn't read pes_id" — true but
|
||||
misleading.** GameSky doesn't read it directly; the script-hook
|
||||
system does. `CelestialPosition.pes_id` (struct offset +0x004) is
|
||||
populated by `SkyDesc::GetSky` and consumed downstream by
|
||||
`CallPESHook` / `CreateParticleHook` invocations scheduled from
|
||||
region/weather handlers.
|
||||
|
||||
## Decomp anchors
|
||||
|
||||
All addresses verified live against `refs/acclient.pdb`:
|
||||
|
||||
| Function | Address | Role |
|
||||
|---|---|---|
|
||||
| `CallPESHook::Execute` | `0x00526e20` | Script-hook action that fires CallPES |
|
||||
| `CreateParticleHook::Execute` | `0x00526ec0` | Particle-creation hook |
|
||||
| `CPhysicsObj::CallPES` | `0x00511af0` | Top-level PES dispatch |
|
||||
| `CPhysicsObj::CallPESInternal` | `0x00511ac0` | Inner dispatch (never fires alone in trace — likely inlined) |
|
||||
| `CPhysicsObj::create_particle_emitter` | `0x0050f360` | Creates a new emitter |
|
||||
| `CPhysicsObj::create_blocking_particle_emitter` | `0x0050f3b0` | Creates a blocking emitter |
|
||||
| `CPhysicsObj::stop_particle_emitter` | `0x0050f420` | |
|
||||
| `CPhysicsObj::destroy_particle_emitter` | `0x0050f400` | |
|
||||
| `CPhysicsObj::ShouldDrawParticles` | `0x0050fe60` | |
|
||||
| `CPhysicsObj::makeParticleObject` | `0x00512640` | |
|
||||
| `CPartArray::CreateParticle` | `0x005194f0` | |
|
||||
| `CSetup::makeParticleSetup` | `0x005201f0` | |
|
||||
| `LongNIHash<ParticleEmitter>::add` | `0x005198c0` | Emitter registry insertion |
|
||||
| `GameSky::CreateDeletePhysicsObjects` | `0x005073c0` | Already partially decoded; entry point for sky-object lifecycle |
|
||||
| `GameSky::UseTime` | `0x005075b0` | Per-frame sky update (30Hz) |
|
||||
| `GameSky::Draw` | `0x00506ff0` | Sky render (60Hz) |
|
||||
| `SkyDesc::GetSky` | `0x00501ec0` | Populates `CelestialPosition.pes_id` |
|
||||
|
||||
`CelestialPosition` struct layout:
|
||||
```
|
||||
+0x000 gfx_id : 4 bytes
|
||||
+0x004 pes_id : 4 bytes ← THIS is what gets PES-driven
|
||||
+0x008 heading : float
|
||||
+0x00c rotation : float
|
||||
+0x010 tex_velocity : Vector3
|
||||
+0x01c transparent : float
|
||||
+0x020 luminosity : float
|
||||
+0x024 max_bright : float
|
||||
+0x028 properties : 4 bytes
|
||||
```
|
||||
|
||||
## Phase plan
|
||||
|
||||
### Phase M.1 — Decomp dive (no code changes)
|
||||
|
||||
Read these functions in order. Save findings to
|
||||
`docs/research/2026-04-30-sky-pes-pseudocode.md`:
|
||||
|
||||
1. **`CallPESHook::Execute`** at `0x00526e20`. What state does it
|
||||
read? What does it call? Answer: probably "look up the target
|
||||
CPhysicsObj by some ID, call CallPES on it." Confirm.
|
||||
|
||||
2. **`CPhysicsObj::CallPES`** at `0x00511af0`. What does it do?
|
||||
Probably: "look up or load the PES file referenced by `pes_id`,
|
||||
start running its script timeline." Find the script-evaluation
|
||||
loop and where it dispatches `CallPESHook` events.
|
||||
|
||||
3. **`CreateParticleHook::Execute`** at `0x00526ec0`. When this hook
|
||||
fires, what does it create? What CPhysicsObj does it attach the
|
||||
emitter to? Probably calls `CPhysicsObj::create_particle_emitter`.
|
||||
|
||||
4. **`CPhysicsObj::create_particle_emitter`** at `0x0050f360`. What
|
||||
does it instantiate? What goes into the `LongNIHash<ParticleEmitter>`?
|
||||
|
||||
5. **`GameSky::CreateDeletePhysicsObjects`** at `0x005073c0`. The
|
||||
prior research said this doesn't read pes_id. Confirm — but ALSO
|
||||
check: does it set up the script-hook timeline somewhere? Or does
|
||||
that happen in a separate caller?
|
||||
|
||||
6. **The dynamic-emitter-spawn trigger.** The trace caught a +47
|
||||
burst — find what fires CreateParticleHook on region / weather /
|
||||
time-of-day transitions. Likely candidates:
|
||||
- `LScape` weather handler
|
||||
- `CDayCycle` / `CWorldFog` region handler
|
||||
- Cell-load or cell-cross handler
|
||||
|
||||
### Phase M.2 — Verify with detailed cdb trace (one focused session)
|
||||
|
||||
After M.1 reveals the wiring, attach cdb to retail and capture:
|
||||
|
||||
- `this` pointer + `pes_id` arg on every `CPhysicsObj::CallPES`
|
||||
- GfxObj being attached on every `create_particle_emitter`
|
||||
- Stack walk at `CallPESHook::Execute` to confirm the caller chain
|
||||
- Watch for the dynamic +N burst — what global state changed at
|
||||
that frame?
|
||||
|
||||
The data should match the M.1 decomp predictions. If it diverges,
|
||||
the decomp interpretation needs another pass.
|
||||
|
||||
### Phase M.3 — Implementation (acdream port)
|
||||
|
||||
1. **Persistent emitter creation at cell load.** When a sky-bearing
|
||||
landblock loads, walk SkyDesc / CelestialPosition entries; for
|
||||
each entry with non-zero `pes_id`, instantiate a ParticleEmitter
|
||||
on the corresponding sky CPhysicsObj.
|
||||
|
||||
2. **Dynamic emitter spawn on transitions.** Hook into our region /
|
||||
weather / day-cycle change events; replicate retail's dispatch.
|
||||
|
||||
3. **PES script-timeline driver.** Port the scheduler that fires
|
||||
`CallPESHook` events at script-defined moments. May reuse
|
||||
`references/holtburger` if there's a Rust port. If not, port from
|
||||
decomp directly.
|
||||
|
||||
4. **Particle-system rendering wire-up.** acdream already has a
|
||||
particle system (R3 era). Verify it can accept emitter spawns
|
||||
from this path. If so, just wire. If not, identify the gap.
|
||||
|
||||
5. **Surface 0x08000023 / cloud GfxObjs.** Once dynamic emitters spawn,
|
||||
#29's "clouds too thin" should resolve naturally.
|
||||
|
||||
### Phase M.4 — Live verification
|
||||
|
||||
1. Retail + acdream side-by-side. Aurora moment (Rainy DayGroup,
|
||||
dusk/dawn). Compare visual.
|
||||
2. Cloudy moment — clouds should look as dense as retail.
|
||||
3. Storm moment — lightning flashes (covers part of #2).
|
||||
4. Run another cdb trace; counts should match retail's counts within
|
||||
~10%.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Aurora visible in acdream at the same in-game moments retail shows it.
|
||||
- Cloud sheets look as dense / purple as retail.
|
||||
- Storm flash visible during Rainy storm windows (part of #2).
|
||||
- New cdb trace shows similar PES dispatch rate (~150/min) and similar
|
||||
emitter spawn pattern (initial population + transition bursts).
|
||||
- Closes #28, #29. Updates #2 with the storm-flash story.
|
||||
|
||||
## What this doesn't fix
|
||||
|
||||
- **#4 horizon-glow** is a separate issue (fog parameters, not particles).
|
||||
Tackle that in a different session — different code path, different
|
||||
cdb trace.
|
||||
- **Lightning timing / thunder** (the audio half of #2) is separate;
|
||||
needs the audio system wired.
|
||||
|
||||
## How to start
|
||||
|
||||
The next session, having read CLAUDE.md (auto-loaded) and
|
||||
`memory/project_retail_debugger.md` (auto-loaded), should:
|
||||
|
||||
1. Read this plan top to bottom (~5 minutes).
|
||||
2. Begin Phase M.1 decomp dive — no code yet, just understand the
|
||||
wiring. Save findings to a research doc.
|
||||
3. After M.1 lands, decide whether M.2 (verify with cdb) is needed
|
||||
before M.3 (implement). Often the decomp alone is enough; M.2 is
|
||||
for resolving ambiguity.
|
||||
|
||||
The cdb tooling is ready (CLAUDE.md "Retail debugger toolchain").
|
||||
The user can launch retail with `C:\Turbine\Asheron's Call\acclient.exe`
|
||||
on demand.
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
# Retail motion outbound trace — findings
|
||||
|
||||
**Date:** 2026-05-01
|
||||
**Tool:** cdb 10.0.28000.1839 attached non-invasively to live retail
|
||||
acclient.exe v11.4186 (Sept 2013 EoR build, GUID
|
||||
`9e847e2f-777c-4bd9-886c-22256bb87f32`).
|
||||
**Symbols:** `refs/acclient.pdb` — 18,366 named functions, 5,371 named
|
||||
struct/class types.
|
||||
**Server:** local ACE on `127.0.0.1:9000`. Character: `+Acdream`,
|
||||
spawned in Holtburg.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **WASD movement does NOT go through `ACCmdInterp::SendDoMovementEvent`.**
|
||||
Across two captures totalling ~5 minutes of running, jumping, and
|
||||
turning, that breakpoint fired **zero** times. `SendDoMovementEvent` is
|
||||
apparently slash-command-only (`/run`, `/walk`, `/sneak`, etc.). The
|
||||
per-frame movement send path is `CommandInterpreter::SendMovementEvent`
|
||||
(which we couldn't trace stably — see "Open" below).
|
||||
- **Jump goes through `CM_Movement::Event_Jump`.** It fires
|
||||
**per-frame while the spacebar is held** (charge phase), not just once
|
||||
per jump. 44 hits in ~30 sec of jumping. Same `JumpPack` stack pointer
|
||||
every time — function probably packs every frame but conditionally
|
||||
sends.
|
||||
- **AutoPos heartbeat fires often during sustained motion** (~5 Hz at
|
||||
rest after gating, much higher under motion). It's gated by
|
||||
`transient_state & 1 && transient_state & 2 && Position::IsValid` so
|
||||
**does not fire when the player is standing still**.
|
||||
- **`set_heading` is for player rotation only, not camera mouse-look.**
|
||||
Camera pan does not fire it; turn keys / strafe / character autoface do.
|
||||
- **`set_state`** XOR mask shows which physics-state bits change on each
|
||||
call. Distinct values observed: 11 different new-state bitmasks
|
||||
across two captures (see "set_state bitmask atlas" below).
|
||||
|
||||
## What we ran
|
||||
|
||||
| Run | Duration | Activity | Hits |
|
||||
|-----|----------|----------|------|
|
||||
| v1 | ~60 sec | Run forward + turn | 127 |
|
||||
| v2 | ~3 min | Run + jump + turn (holding W, repeated jumps) | 247 |
|
||||
|
||||
Both with the same six-breakpoint set:
|
||||
`SendDoMovementEvent`, `SendStopMovementEvent`, `set_state`,
|
||||
`set_heading`, `Event_Jump`, `Event_AutonomousPosition`. Logs at
|
||||
[motion_trace_v1_walk_and_turn_2026-05-01.log](../../../.claude/worktrees/jovial-blackburn-773942/motion_trace_v1_walk_and_turn_2026-05-01.log)
|
||||
and [motion_trace_v2_run_jump_turn_2026-05-01.log](../../../.claude/worktrees/jovial-blackburn-773942/motion_trace_v2_run_jump_turn_2026-05-01.log).
|
||||
|
||||
A v3 attempt added `CommandInterpreter::SendMovementEvent` (the
|
||||
per-frame MoveToState gateway). The cdb attach + breakpoint setup with
|
||||
that 7th breakpoint added enough overhead that retail's network thread
|
||||
starved; ACE timed out the session and retail crashed within seconds of
|
||||
attach. That bp is too high-frequency to instrument with a `printf` in
|
||||
the action — needs a separate, minimal-overhead trace (counter only,
|
||||
no print).
|
||||
|
||||
## Hit distribution (v2)
|
||||
|
||||
| BP | Hits | Notes |
|
||||
|----|------|-------|
|
||||
| `set_state` | 159 | All 11 distinct new-masks; clusters around motion transitions |
|
||||
| `Event_Jump` | 44 | Bursts during sustained spacebar-hold |
|
||||
| `Event_AutonomousPosition` (printed) | 38 | Throttle 1/10 → ~370 actual hits |
|
||||
| `set_heading` (printed) | 6 | Throttle 1/10 → ~60 actual; only fires on player rotate |
|
||||
| `SendDoMovementEvent` | 0 | **Confirmed unused for keyboard motion** |
|
||||
| `SendStopMovementEvent` | 0 | **Confirmed unused** |
|
||||
|
||||
## set_state bitmask atlas
|
||||
|
||||
Captured `new` masks (current state was `0x00400c08` baseline, sometimes
|
||||
`0x00410c08`). Distinct values across both runs:
|
||||
|
||||
| `new` | bits set | Likely meaning (cross-checked vs `PHYSICS_STATE` enum candidates) |
|
||||
|-------|----------|--------------------------------------------------------------------|
|
||||
| `0x00000408` | 3, 10 | Base "in world, animating" |
|
||||
| `0x00000410` | 4, 10 | Walk / sidestep variant |
|
||||
| `0x00000414` | 2, 4, 10 | Walking + something |
|
||||
| `0x00000418` | 3, 4, 10 | Walking + alt |
|
||||
| `0x00000c0c` | 2, 3, 10, 11 | Multi-state combo |
|
||||
| `0x00000c14` | 2, 4, 10, 11 | Combo near jump |
|
||||
| `0x00010008` | 3, 16 | Bit 16 likely "scripted/cinematic"? |
|
||||
| `0x00020414` | 2, 4, 10, 17 | Bit 17 likely something high |
|
||||
| `0x00200418` | 3, 4, 10, 21 | Bit 21 likely "frozen/inert" |
|
||||
| `0x00600418` | 3, 4, 10, 21, 22 | Bits 21+22 — common during motion |
|
||||
| `0x0060041c` | 2, 3, 4, 10, 21, 22 | Same + bit 2 |
|
||||
|
||||
**Action item:** decode the `PhysicsState` enum bits from
|
||||
`docs/research/named-retail/acclient.h` and label this table
|
||||
authoritatively. The `cur` value `0x00400c08` (bits 3, 10, 11, 22)
|
||||
should be the "neutral, in-world, alive" base.
|
||||
|
||||
## set_heading angle samples
|
||||
|
||||
Angles captured (4-byte float bit pattern, decoded):
|
||||
|
||||
| Hex | Float (degrees) |
|
||||
|-----|-----------------|
|
||||
| `0x00000000` | 0.0 (north) |
|
||||
| `0x41c8af72` | 25.09 |
|
||||
| `0x43070000` | 135.0 |
|
||||
| `0x43083c22` | 136.23 |
|
||||
| `0x430d7596` | 141.46 |
|
||||
| `0x43340000` | 180.0 |
|
||||
| `0x433a1741` | 186.09 |
|
||||
| `0x43a9d6c7` | 339.68 |
|
||||
|
||||
Confirms heading is **degrees, float, 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.
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
# acdream vs retail motion outbound — gap analysis + fixes
|
||||
|
||||
Companion to [findings.md](findings.md). Compares acdream's current
|
||||
outbound motion code to retail's observed behaviour AND to holtburger
|
||||
(the most authoritative working AC client we have, Rust TUI).
|
||||
|
||||
## Summary table
|
||||
|
||||
| Behaviour | Retail (live trace) | Holtburger (Rust client) | acdream | Verdict |
|
||||
|-----------|---------------------|--------------------------|---------|---------|
|
||||
| MoveToState dispatch trigger | Per-frame in `CommandInterpreter::SendMovementEvent`, gated by `InqRawMotionState != 0` and a `last_sent_position_time` rate-limit (rate unknown) | On motion-intent change (`last_server_motion_intent != current`) | On motion-state change (`MotionStateChanged`) | acdream ≈ holtburger; retail probably more aggressive but rate-limited — **likely OK** |
|
||||
| AutonomousPosition cadence | ~1 Hz observed in v2 (~380 hits over ~5 min of activity) | **1.0 sec** const (`AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL`) | **0.2 sec** (5 Hz) | **acdream is 5× too aggressive** |
|
||||
| AutoPos at rest | Likely yes (gated by `transient_state & 1 && transient_state & 2`, not by motion) | Yes (gated by `has_autonomous_position_sync_target`, not motion) | **No** — acdream only ticks heartbeat while `isMoving` | **acdream MISSING at-rest heartbeat** |
|
||||
| MoveToState content (heading) | float degrees 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,9 +72,3 @@ InputDispatcher / PlayerMovementController
|
|||
step-down edge cases run the retail back-probe before precipice slide.
|
||||
`cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full
|
||||
`cell_bsp`, and real-DAT building portal conformance remain open L.2 work.
|
||||
- 2026-04-30: Edge-slide retry-loop lesson. `SPHEREPATH::precipice_slide`
|
||||
usually returns `Slid` after applying the tangent offset. That result must
|
||||
be handled inside `TransitionalInsert` like wall slide (`continue` and
|
||||
re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the
|
||||
outer validator treats non-OK as a collision and restores `CurPos`, making
|
||||
edges feel like hard stops even when the tangent was computed.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ public readonly record struct MovementResult(
|
|||
float LocalAnimationSpeed = 1f,
|
||||
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
|
||||
float? JumpExtent = null, // non-null when a jump was triggered this frame
|
||||
Vector3? JumpVelocity = null); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast.
|
||||
Vector3? JumpVelocity = null); // world-space launch velocity (sent in jump packet)
|
||||
|
||||
/// <summary>
|
||||
/// Portal-space state for the player movement controller.
|
||||
|
|
@ -168,41 +168,10 @@ public sealed class PlayerMovementController
|
|||
private uint? _prevLocalAnimCmd;
|
||||
|
||||
// Heartbeat timer.
|
||||
// Cadence is 1.0 sec to match holtburger's
|
||||
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
|
||||
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
|
||||
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
|
||||
// but wasteful and probably looked like jitter to observers.
|
||||
private float _heartbeatAccum;
|
||||
public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger
|
||||
public const float HeartbeatInterval = 0.2f; // 200ms
|
||||
public bool HeartbeatDue { get; private set; }
|
||||
|
||||
// L.5 retail physics-tick gate (2026-04-30).
|
||||
//
|
||||
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
|
||||
// MinQuantum (1/30s) sized integration steps, SKIPPING entirely when
|
||||
// accumulated dt is below MinQuantum. The retail debugger trace
|
||||
// confirmed this: UpdatePhysicsInternal fires only ~61% as often as
|
||||
// update_object — i.e., retail's effective physics tick rate is 30Hz
|
||||
// even when the renderer runs at 60+Hz.
|
||||
//
|
||||
// Without this gate our acdream integrates at the full render rate
|
||||
// (60+Hz), which compresses bounce-energy / gravity-tangent
|
||||
// accumulation into half the time. Per-frame V grows ~2x faster than
|
||||
// retail's. On a steep-slope tangent that produces the wedge: V grows
|
||||
// tangent + huge while position reverts each frame, body locks in
|
||||
// place. Retail's slower integration cadence (and larger per-tick
|
||||
// position deltas) lets the body geometrically escape the tangent.
|
||||
//
|
||||
// Source: retail debugger trace 2026-04-30
|
||||
// update_object = 40,960 calls
|
||||
// UpdatePhysicsInternal = 25,087 calls (61%)
|
||||
// ratio implies 39% of frames return early via the MinQuantum gate.
|
||||
//
|
||||
// ACE: PhysicsObj.UpdateObject (Physics.cs).
|
||||
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
|
||||
private float _physicsAccum;
|
||||
|
||||
public PlayerMovementController(PhysicsEngine physics)
|
||||
{
|
||||
_physics = physics;
|
||||
|
|
@ -433,106 +402,18 @@ public sealed class PlayerMovementController
|
|||
var jumpResult = _motion.jump(_jumpExtent);
|
||||
if (jumpResult == WeenieError.None)
|
||||
{
|
||||
// Capture jump_v_z BEFORE LeaveGround() — that call resets
|
||||
// JumpExtent back to 0 (faithful to retail's FUN_00529710),
|
||||
// after which get_jump_v_z() returns 0 because the extent
|
||||
// gate at the top of the function fires.
|
||||
float jumpVz = _motion.get_jump_v_z();
|
||||
_motion.LeaveGround();
|
||||
outJumpExtent = _jumpExtent;
|
||||
// BODY-LOCAL jump-launch velocity, computed directly from input.
|
||||
//
|
||||
// Why not read _body.Velocity? Because _motion.LeaveGround()
|
||||
// routes through get_leave_ground_velocity → get_state_velocity,
|
||||
// which is a faithful port of retail's FUN_00528960. Retail's
|
||||
// version only handles WalkForward (0x45000005) / RunForward
|
||||
// (0x44000007) / SideStepRight (0x6500000F); WalkBackwards
|
||||
// and SideStepLeft return zero. Retail papers over this in
|
||||
// adjust_motion (FUN_00528010) by translating
|
||||
// WalkBackwards → WalkForward + speed × -0.65
|
||||
// SideStepLeft → SideStepRight + speed × -1
|
||||
// before they reach InterpretedState — but we don't yet port
|
||||
// adjust_motion, so InterpretedState holds the un-translated
|
||||
// command and get_state_velocity returns (0,0,0) for it.
|
||||
// LeaveGround then writes (0,0,jumpZ) to the body, wiping the
|
||||
// correct strafe/backward velocity the controller had just set
|
||||
// a few lines up. Result: backward/strafe jumps go straight up.
|
||||
//
|
||||
// Until adjust_motion is ported, we mirror the grounded-velocity
|
||||
// computation from the block above and stuff the result into
|
||||
// outJumpVelocity directly. Local frame: +Y forward, +X right,
|
||||
// +Z up — matches retail's body-frame convention. Server
|
||||
// rotates body→world on receive, so observers see the jump
|
||||
// in the correct world direction.
|
||||
float jumpRunMul = 1.0f;
|
||||
if (input.Run && _weenie.InqRunRate(out float jvrr))
|
||||
jumpRunMul = jvrr;
|
||||
|
||||
// Forward uses get_state_velocity (which knows Walk vs Run vs
|
||||
// animation-cycle pacing). Backward / Strafe use the same
|
||||
// hardcoded scaled formulas the grounded-velocity block above
|
||||
// uses (lines 397-408).
|
||||
float localY = 0f;
|
||||
if (input.Forward)
|
||||
{
|
||||
var stateVel = _motion.get_state_velocity();
|
||||
localY = stateVel.Y;
|
||||
}
|
||||
else if (input.Backward)
|
||||
{
|
||||
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * jumpRunMul);
|
||||
}
|
||||
|
||||
float localX = 0f;
|
||||
if (input.StrafeRight)
|
||||
localX = MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
|
||||
else if (input.StrafeLeft)
|
||||
localX = -MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
|
||||
|
||||
outJumpVelocity = new Vector3(localX, localY, jumpVz);
|
||||
|
||||
// Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ)
|
||||
// to the body for backward/strafe-left (same get_state_velocity
|
||||
// zero-for-non-canonical-motion bug as on the wire side).
|
||||
// Push the corrected body-local velocity back so the local
|
||||
// client renders the jump in the same world direction the
|
||||
// server is broadcasting to observers. Same vector we just
|
||||
// sent in JumpAction — local + remote stay in sync.
|
||||
_body.set_local_velocity(outJumpVelocity.Value);
|
||||
outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it
|
||||
}
|
||||
_jumpCharging = false;
|
||||
_jumpExtent = 0f;
|
||||
}
|
||||
|
||||
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
|
||||
//
|
||||
// L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj::
|
||||
// update_object skips integration when accumulated dt is below
|
||||
// MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz
|
||||
// render. We accumulate per-frame dt and only integrate (with the
|
||||
// accumulated dt) when the threshold is reached. See _physicsAccum
|
||||
// declaration for the full retail trace evidence.
|
||||
var preIntegratePos = _body.Position;
|
||||
_physicsAccum += dt;
|
||||
|
||||
if (_physicsAccum > PhysicsBody.HugeQuantum)
|
||||
{
|
||||
// Stale frame (debugger break, GC pause). Discard accumulated dt.
|
||||
_physicsAccum = 0f;
|
||||
}
|
||||
else if (_physicsAccum >= PhysicsBody.MinQuantum)
|
||||
{
|
||||
// Integrate accumulated dt, clamped to MaxQuantum so a long
|
||||
// pause doesn't produce one giant integration step.
|
||||
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
|
||||
_body.calc_acceleration();
|
||||
_body.UpdatePhysicsInternal(tickDt);
|
||||
_physicsAccum -= tickDt;
|
||||
}
|
||||
// Else: dt below MinQuantum threshold — skip integration. Position
|
||||
// and velocity remain unchanged; Resolve below runs as a zero-distance
|
||||
// sphere sweep (no collision possible) and the rest of the frame
|
||||
// (motion commands, animation, return) runs normally.
|
||||
_body.calc_acceleration();
|
||||
_body.UpdatePhysicsInternal(dt);
|
||||
var postIntegratePos = _body.Position;
|
||||
|
||||
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
|
||||
|
|
@ -560,19 +441,6 @@ public sealed class PlayerMovementController
|
|||
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
|
||||
|
||||
// L.4-diag (2026-04-30): trace position transitions so we can see
|
||||
// whether the body is actually moving frame-to-frame on the steep
|
||||
// roof, or whether it's frozen at the impact point.
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
|
||||
&& resolveResult.CollisionNormalValid)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " +
|
||||
$"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " +
|
||||
$"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " +
|
||||
$"isOnGround={resolveResult.IsOnGround}");
|
||||
}
|
||||
|
||||
// Apply resolved position.
|
||||
_body.Position = resolveResult.Position;
|
||||
|
||||
|
|
@ -639,21 +507,6 @@ public sealed class PlayerMovementController
|
|||
? !(prevOnWalkable && nowOnWalkable)
|
||||
: (!prevOnWalkable && !nowOnWalkable);
|
||||
|
||||
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
|
||||
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||||
if (diagSteep && resolveResult.CollisionNormalValid)
|
||||
{
|
||||
var n0 = resolveResult.CollisionNormal;
|
||||
var v0 = _body.Velocity;
|
||||
Console.WriteLine(
|
||||
$"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
|
||||
$"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
|
||||
$"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
|
||||
$"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
|
||||
$"dot={Vector3.Dot(v0, n0):F3} " +
|
||||
$"isOnGround={resolveResult.IsOnGround}");
|
||||
}
|
||||
|
||||
if (applyBounce)
|
||||
{
|
||||
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
|
||||
|
|
@ -673,13 +526,6 @@ public sealed class PlayerMovementController
|
|||
// velocity reflects (subtle bounce).
|
||||
float k = -(dotVN * (_body.Elasticity + 1f));
|
||||
_body.Velocity = v + n * k;
|
||||
|
||||
if (diagSteep)
|
||||
{
|
||||
var v1 = _body.Velocity;
|
||||
Console.WriteLine(
|
||||
$"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -830,19 +676,21 @@ public sealed class PlayerMovementController
|
|||
return System.Math.Abs(a.Value - b.Value) < 1e-4f;
|
||||
}
|
||||
|
||||
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
|
||||
// Holtburger fires AutonomousPosition heartbeat at 1 Hz regardless of
|
||||
// motion state (gated only by has_autonomous_position_sync_target).
|
||||
// Retail's CommandInterpreter::SendPositionEvent gates on
|
||||
// transient_state (Contact + OnWalkable + valid Position), not on
|
||||
// motion. The pre-fix isMoving gate stopped acdream from heart-beating
|
||||
// at rest, which left observers with stale last-known positions during
|
||||
// long idle periods. PortalSpace (handled at the top of Update via
|
||||
// early return) skips Update entirely, so reaching this line implies
|
||||
// we're in a valid in-world pose.
|
||||
_heartbeatAccum += dt;
|
||||
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
|
||||
if (HeartbeatDue) _heartbeatAccum = 0f;
|
||||
// ── 8. Heartbeat timer (only while moving) ────────────────────────────
|
||||
bool isMoving = outForwardCmd is not null
|
||||
|| outSidestepCmd is not null
|
||||
|| outTurnCmd is not null;
|
||||
if (isMoving)
|
||||
{
|
||||
_heartbeatAccum += dt;
|
||||
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
|
||||
if (HeartbeatDue) _heartbeatAccum = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
_heartbeatAccum = 0f;
|
||||
HeartbeatDue = false;
|
||||
}
|
||||
|
||||
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
|
||||
// should match the actual movement speed. For Forward+Run this is
|
||||
|
|
@ -867,14 +715,7 @@ public sealed class PlayerMovementController
|
|||
ForwardSpeed: outForwardSpeed,
|
||||
SidestepSpeed: outSidestepSpeed,
|
||||
TurnSpeed: outTurnSpeed,
|
||||
// Run hold-key applies to ANY active directional axis, not just
|
||||
// forward (per holtburger's build_motion_state_raw_motion_state:
|
||||
// "uses the same value for every active per-axis hold key"). The
|
||||
// pre-fix condition `input.Run && input.Forward` made strafe-run
|
||||
// and backward-run incorrectly broadcast as walk to observers,
|
||||
// who then animated walk + dead-reckoned at walk speed while the
|
||||
// server position moved at run speed — visible as observer lag.
|
||||
IsRunning: input.Run && anyDirectional,
|
||||
IsRunning: input.Run && input.Forward,
|
||||
LocalAnimationCommand: localAnimCmd,
|
||||
LocalAnimationSpeed: localAnimSpeed,
|
||||
JustLanded: justLanded,
|
||||
|
|
|
|||
|
|
@ -168,10 +168,6 @@ public sealed class GameWindow : IDisposable
|
|||
// Keep the experimental path available for DAT archaeology only.
|
||||
private readonly bool _enableSkyPesDebug =
|
||||
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_ENABLE_SKY_PES"), "1", StringComparison.Ordinal);
|
||||
|
||||
// Diagnostic: hide a specific humanoid part (>=10 parts) at render.
|
||||
private static readonly int s_hidePartIndex =
|
||||
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1;
|
||||
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
|
||||
private readonly HashSet<SkyPesKey> _missingSkyPes = new();
|
||||
|
||||
|
|
@ -1916,31 +1912,6 @@ public sealed class GameWindow : IDisposable
|
|||
// then proceed with the normal upload loop.
|
||||
var parts = new List<AcDream.Core.World.MeshRef>(flat);
|
||||
var animPartChanges = spawn.AnimPartChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.AnimPartChange>();
|
||||
// Diagnostic: dump AnimPartChanges + TextureChanges for humanoid setups
|
||||
// gated on ACDREAM_DUMP_CLOTHING=1. Used to verify whether the server is
|
||||
// sending coverage for the neck (part 9 for Aluvian Male) etc.
|
||||
bool dumpClothing = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CLOTHING"), "1", StringComparison.Ordinal)
|
||||
&& setup.Parts.Count >= 10;
|
||||
if (dumpClothing)
|
||||
{
|
||||
Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} APC={animPartChanges.Count} ===");
|
||||
foreach (var c in animPartChanges)
|
||||
Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}");
|
||||
|
||||
// #37: per-spawn palette swaps. The server's clothing pipeline
|
||||
// sends a basePalette + a list of (subPaletteId, offset, length)
|
||||
// triples that splice palette ranges into the rendered character.
|
||||
// We need their IDs to know whether the coat texture's underlying
|
||||
// palette is being overridden by a coat-tone subPalette or left
|
||||
// alone (in which case the texture's DefaultPaletteId — a SKIN
|
||||
// palette — leaks through and the coat ends up neck-colored).
|
||||
Console.WriteLine($" basePalette=0x{(spawn.BasePaletteId ?? 0):X8} subPalettes={(spawn.SubPalettes?.Count ?? 0)}");
|
||||
if (spawn.SubPalettes is { } subPaletteList)
|
||||
{
|
||||
foreach (var subPal in subPaletteList)
|
||||
Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}");
|
||||
}
|
||||
}
|
||||
foreach (var change in animPartChanges)
|
||||
{
|
||||
if (change.PartIndex < parts.Count)
|
||||
|
|
@ -1961,45 +1932,6 @@ public sealed class GameWindow : IDisposable
|
|||
// to get a texture decoded with the replacement SurfaceTexture
|
||||
// substituted inside the Surface's decode chain.
|
||||
var textureChanges = spawn.TextureChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.TextureChange>();
|
||||
if (dumpClothing)
|
||||
{
|
||||
Console.WriteLine($" TextureChanges count={textureChanges.Count}");
|
||||
foreach (var tc in textureChanges)
|
||||
Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}");
|
||||
|
||||
// For each part (post-AnimPartChange), dump its Surface chain so we
|
||||
// can see which OrigTextureIds the part references and check which
|
||||
// are covered by our TextureChanges.
|
||||
var tcByPart = new Dictionary<int, HashSet<uint>>();
|
||||
foreach (var tc in textureChanges)
|
||||
{
|
||||
if (!tcByPart.TryGetValue(tc.PartIndex, out var set)) { set = new HashSet<uint>(); tcByPart[tc.PartIndex] = set; }
|
||||
set.Add(tc.OldTexture);
|
||||
}
|
||||
for (int pi = 0; pi < parts.Count; pi++)
|
||||
{
|
||||
var pgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(parts[pi].GfxObjId);
|
||||
if (pgfx is null) continue;
|
||||
if (pgfx.Surfaces.Count == 0) continue;
|
||||
tcByPart.TryGetValue(pi, out var coveredOldTex);
|
||||
int matched = 0;
|
||||
int unmatched = 0;
|
||||
var unmatchedList = new List<string>();
|
||||
foreach (var surfQid in pgfx.Surfaces)
|
||||
{
|
||||
uint surfId = (uint)surfQid;
|
||||
var surf = _dats.Get<DatReaderWriter.DBObjs.Surface>(surfId);
|
||||
if (surf is null) continue;
|
||||
uint origTex = (uint)surf.OrigTextureId;
|
||||
if (coveredOldTex is not null && coveredOldTex.Contains(origTex)) matched++;
|
||||
else { unmatched++; unmatchedList.Add($"surf=0x{surfId:X8} origTex=0x{origTex:X8}"); }
|
||||
}
|
||||
if (pgfx.Surfaces.Count > 0)
|
||||
Console.WriteLine($" part[{pi:D2}] gfx=0x{parts[pi].GfxObjId:X8} surfaces={pgfx.Surfaces.Count} matched={matched} unmatched={unmatched}");
|
||||
foreach (var s in unmatchedList)
|
||||
Console.WriteLine($" UNMATCHED {s}");
|
||||
}
|
||||
}
|
||||
Dictionary<int, Dictionary<uint, uint>>? resolvedOverridesByPart = null;
|
||||
if (textureChanges.Count > 0)
|
||||
{
|
||||
|
|
@ -2736,14 +2668,7 @@ public sealed class GameWindow : IDisposable
|
|||
// get_state_velocity returns 0 because the gate is
|
||||
// RunForward||WalkForward — body stops moving forward.
|
||||
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
|
||||
// Pass speedMod through verbatim — preserve sign so retail's
|
||||
// adjust_motion'd backward walk (cmd=WalkForward, spd<0)
|
||||
// produces backward velocity in get_state_velocity, NOT
|
||||
// forward. Pre-fix used `<=0 ? 1 : speedMod` which clamped
|
||||
// negative to 1.0 and made the dead-reckoned body translate
|
||||
// forward despite the reverse-playback animation — visually
|
||||
// "still walking forward" from the observer's POV.
|
||||
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod;
|
||||
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod;
|
||||
|
||||
if (update.MotionState.IsServerControlledMoveTo
|
||||
&& update.MotionState.MoveToPath is { } path)
|
||||
|
|
@ -6111,10 +6036,6 @@ public sealed class GameWindow : IDisposable
|
|||
partTransform = partTransform * scaleMat;
|
||||
|
||||
var template = ae.PartTemplate[i];
|
||||
if (s_hidePartIndex >= 0 && i == s_hidePartIndex && partCount >= 10)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
|
||||
{
|
||||
SurfaceOverrides = template.SurfaceOverrides,
|
||||
|
|
|
|||
|
|
@ -207,11 +207,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
}
|
||||
|
||||
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
|
||||
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling entirely.
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
}
|
||||
foreach (var (key, grp) in _groups)
|
||||
{
|
||||
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
|
||||
|
|
@ -273,19 +268,9 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.DepthMask(false);
|
||||
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling (used 2026-05-01
|
||||
// to test if our mesh winding (0,i,i+1) vs ACME's (i+1,i,0) is causing
|
||||
// visible polygons to be culled, especially around the neck/coat seam).
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
}
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
|
||||
foreach (var (key, grp) in _groups)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -389,18 +389,11 @@ public sealed class AnimationSequencer
|
|||
// This keeps the run/walk loop smooth when a new UpdateMotion arrives
|
||||
// with a different ForwardSpeed (e.g. when the server broadcasts a
|
||||
// player's updated RunRate mid-step).
|
||||
//
|
||||
// **Sign-flip case (2026-05-02):** when the server sends adjust_motion'd
|
||||
// backward walk as `WalkForward + speed=-N`, motion stays 0x45000005
|
||||
// but speedMod sign flips. We MUST do a full cycle restart in that case
|
||||
// so the new (negative) framerate takes effect; otherwise the cycle
|
||||
// keeps playing forward with the old positive framerate and the
|
||||
// observer sees the player walking forward despite the negative speed.
|
||||
if (CurrentStyle == style && CurrentMotion == motion
|
||||
&& _firstCyclic != null && _queue.Count > 0
|
||||
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod))
|
||||
&& _firstCyclic != null && _queue.Count > 0)
|
||||
{
|
||||
if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f
|
||||
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod)
|
||||
&& MathF.Abs(CurrentSpeedMod) > 1e-6f)
|
||||
{
|
||||
MultiplyCyclicFramerate(speedMod / CurrentSpeedMod);
|
||||
|
|
|
|||
|
|
@ -1473,27 +1473,6 @@ public static class BSPQuery
|
|||
if (changed && hitPoly is not null)
|
||||
{
|
||||
// ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale
|
||||
//
|
||||
// L.4 retail-strict (2026-04-30): the FloorZ gate previously
|
||||
// here was REMOVED after the retail debugger trace + acdream
|
||||
// wedge analysis showed it was preventing the natural escape
|
||||
// path. Retail's flow:
|
||||
// Frame N: airborne sphere hits steep poly. Path 4 commits
|
||||
// ContactPlane on the steep poly (LandingZ ≈ 0.087 is
|
||||
// permissive enough — even 49°+ slopes pass).
|
||||
// Frame N+1: body now grounded with Contact + steep
|
||||
// ContactPlane. OnWalkable cleared by FloorZ test
|
||||
// downstream. Resolver fires Path 5 (Contact branch)
|
||||
// instead of Path 6. step_sphere_up tries to step over,
|
||||
// fails, falls back to step_up_slide → clears Contact,
|
||||
// slides sphere laterally along StepUpNormal.
|
||||
// Frame N+2: body airborne with lateral V from the slide.
|
||||
// Gravity takes over, body falls off the slope.
|
||||
//
|
||||
// Without this Path-4 commit, the body NEVER gets Contact
|
||||
// set, Path 5 never fires, step_up_slide never runs, and the
|
||||
// body wedges in airborne-collision-revert-loop with V at
|
||||
// MaxVelocity tangent to the surface (live wedge 2026-04-30).
|
||||
var localOffset = validPos.Center - sphere0.Center;
|
||||
var worldOffset = L2W(localOffset) * scale;
|
||||
path.AddOffsetToCheckPos(worldOffset);
|
||||
|
|
@ -1502,6 +1481,7 @@ public static class BSPQuery
|
|||
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
|
||||
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
|
||||
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
|
||||
|
||||
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
|
||||
|
||||
return TransitionState.Adjusted;
|
||||
|
|
@ -1592,57 +1572,16 @@ public static class BSPQuery
|
|||
hitPoly0!, contact0, scale, localToWorld);
|
||||
}
|
||||
|
||||
// ─── SetCollide response ─────────────────────────────────
|
||||
// Airborne sphere hits a polygon. Per retail, call SetCollide
|
||||
// which saves backup position, records StepUpNormal = worldNormal,
|
||||
// and sets WalkInterp=1. TransitionalInsert's Collide branch will
|
||||
// then re-test as Placement to confirm we can land on the surface.
|
||||
//
|
||||
// ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
|
||||
// + return Adjusted.
|
||||
// Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
|
||||
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
|
||||
|
||||
// L.4 slide-tangent for steep airborne hits (2026-04-30).
|
||||
//
|
||||
// For polygons too steep to walk on (worldNormal.Z < FloorZ),
|
||||
// skip the SetCollide → Path-4 → ContactPlane landing chain.
|
||||
// That chain commits the body to the steep surface, leading
|
||||
// to the "stuck in falling animation on the roof" bug — once
|
||||
// grounded with a steep ContactPlane, our step_up_slide /
|
||||
// cliff_slide / edge_slide chain can't produce smooth
|
||||
// descent and the body wedges or "falls a bit at a time"
|
||||
// when bumped.
|
||||
//
|
||||
// Instead: project the move along the steep face (remove
|
||||
// the into-wall displacement), set CollisionNormal +
|
||||
// SlidingNormal, return Slid. Same shape as Path 5's
|
||||
// step-up fallback (line 1545-1547) and CylinderCollision
|
||||
// (TransitionTypes.cs:1518-1522). Position is updated in-
|
||||
// place; on the next resolver iteration the sphere is
|
||||
// outside the poly, FindCollisions returns OK, and
|
||||
// ValidateTransition commits the new position. Body stays
|
||||
// airborne, falling animation continues, and gravity's
|
||||
// tangent component drifts the body downhill until it
|
||||
// slides off the slope's edge.
|
||||
//
|
||||
// This is a deliberate deviation from retail (retail uses
|
||||
// SetCollide unconditionally and lets find_walkable +
|
||||
// step_up_slide produce the slide). Validated against
|
||||
// retail debugger trace 2026-04-30: retail body did not
|
||||
// wedge; our retail-faithful port DID wedge because we're
|
||||
// missing implementation details of the step_up_slide /
|
||||
// cliff_slide chain on grounded-steep movement. The
|
||||
// slide-tangent here produces user-acceptable behavior
|
||||
// (slides off naturally) while the deeper chain port is
|
||||
// researched. Filed as L.5+ followup for retail-strict.
|
||||
if (worldNormal0.Z < PhysicsGlobals.FloorZ)
|
||||
{
|
||||
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
|
||||
Vector3 endWorld = path.GlobalSphere[0].Origin;
|
||||
Vector3 gDelta = endWorld - currWorld;
|
||||
float diff = Vector3.Dot(worldNormal0, gDelta);
|
||||
if (diff < 0f)
|
||||
path.AddOffsetToCheckPos(-worldNormal0 * diff);
|
||||
|
||||
collisions.SetCollisionNormal(worldNormal0);
|
||||
collisions.SetSlidingNormal(worldNormal0);
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
|
||||
// ─── SetCollide response (shallow / walkable) ───────────
|
||||
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
|
||||
path.SetCollide(worldNormal0);
|
||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||
return TransitionState.Adjusted;
|
||||
|
|
@ -1658,24 +1597,8 @@ public static class BSPQuery
|
|||
|
||||
if (hit1 || hitPoly1 is not null)
|
||||
{
|
||||
// Head sphere hit: same SetCollide response.
|
||||
var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
|
||||
|
||||
// L.4 slide-tangent: same steep-poly slide for head-sphere.
|
||||
if (worldNormal1.Z < PhysicsGlobals.FloorZ)
|
||||
{
|
||||
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
|
||||
Vector3 endWorld = path.GlobalSphere[0].Origin;
|
||||
Vector3 gDelta = endWorld - currWorld;
|
||||
float diff = Vector3.Dot(worldNormal1, gDelta);
|
||||
if (diff < 0f)
|
||||
path.AddOffsetToCheckPos(-worldNormal1 * diff);
|
||||
|
||||
collisions.SetCollisionNormal(worldNormal1);
|
||||
collisions.SetSlidingNormal(worldNormal1);
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
|
||||
// Head sphere hit shallow surface: SetCollide.
|
||||
path.SetCollide(worldNormal1);
|
||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||
return TransitionState.Adjusted;
|
||||
|
|
|
|||
|
|
@ -594,25 +594,6 @@ public sealed class PhysicsEngine
|
|||
body.SlidingNormal = Vector3.Zero;
|
||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||
}
|
||||
|
||||
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
||||
// Phase 3's reset path sets VelocityKilled when an airborne hit
|
||||
// can't find a walkable surface (steep roof, wall) AND the
|
||||
// body had a last_known_contact_plane (i.e., was grounded
|
||||
// recently). Retail zeros all three velocity components so
|
||||
// gravity restarts cleanly next frame.
|
||||
//
|
||||
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
|
||||
// acclient_2013_pseudo_c.txt:274467-274475
|
||||
// Called from CTransition::transitional_insert reset path:
|
||||
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
|
||||
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
|
||||
if (transition.ObjectInfo.VelocityKilled)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
|
||||
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
|
||||
body.Velocity = Vector3.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// L.3a (2026-04-30): surface the wall normal so callers can apply
|
||||
|
|
|
|||
|
|
@ -78,32 +78,13 @@ public sealed class ObjectInfo
|
|||
public float GetWalkableZ()
|
||||
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
|
||||
|
||||
/// <summary>
|
||||
/// Sticky flag: set by <see cref="StopVelocity"/>; PhysicsEngine consumes
|
||||
/// it after the transition commits to zero the body's velocity. Models
|
||||
/// retail's <c>OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)</c>
|
||||
/// (named-retail acclient_2013_pseudo_c.txt:274467-274475).
|
||||
/// Cleared by the engine when consumed; reset to false at the start of
|
||||
/// each <c>FindTransitionalPosition</c>.
|
||||
/// </summary>
|
||||
public bool VelocityKilled;
|
||||
|
||||
/// <summary>
|
||||
/// Stop any accumulated velocity on this object info.
|
||||
/// Retail: <c>OBJECTINFO::kill_velocity</c> calls
|
||||
/// <c>CPhysicsObj::set_velocity(object, {0,0,0}, 0)</c>
|
||||
/// (named-retail 0x50cfe0).
|
||||
/// ACE: <c>ObjectInfo.StopVelocity</c> — clears Velocity on the physics body.
|
||||
/// <para>
|
||||
/// Velocity lives on <see cref="PhysicsBody"/>, not here. We can't reach
|
||||
/// the body directly from inside the resolver without coupling
|
||||
/// ObjectInfo to it, so we set a flag and let
|
||||
/// <see cref="PhysicsEngine.ResolveWithTransition"/> apply the zero
|
||||
/// after the transition completes. The flag is sticky across the
|
||||
/// outer step loop and consumed exactly once per resolve.
|
||||
/// </para>
|
||||
/// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body.
|
||||
/// acdream: velocity is tracked on PhysicsBody, not here. No-op for now;
|
||||
/// will be wired when velocity is threaded through TransitionalInsert.
|
||||
/// </summary>
|
||||
public void StopVelocity() { VelocityKilled = true; }
|
||||
public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -212,12 +193,6 @@ public sealed class SpherePath
|
|||
public float WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
|
||||
|
||||
public bool LastWalkableValid;
|
||||
public Plane LastWalkablePlane;
|
||||
public Vector3[]? LastWalkableVertices;
|
||||
public Vector3 LastWalkableUp = Vector3.UnitZ;
|
||||
public bool HasLastWalkablePolygon => LastWalkableValid && LastWalkableVertices is { Length: >= 3 };
|
||||
|
||||
// Backup for restore
|
||||
public Vector3 BackupCheckPos;
|
||||
public uint BackupCheckCellId;
|
||||
|
|
@ -281,11 +256,6 @@ public sealed class SpherePath
|
|||
WalkableVertices = (Vector3[])vertices.Clone();
|
||||
WalkableUp = up;
|
||||
WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
|
||||
LastWalkableValid = true;
|
||||
LastWalkablePlane = plane;
|
||||
LastWalkableVertices = (Vector3[])vertices.Clone();
|
||||
LastWalkableUp = up;
|
||||
}
|
||||
|
||||
public void ClearWalkable()
|
||||
|
|
@ -294,18 +264,6 @@ public sealed class SpherePath
|
|||
WalkableVertices = null;
|
||||
}
|
||||
|
||||
public bool RestoreLastWalkable()
|
||||
{
|
||||
if (!HasLastWalkablePolygon || LastWalkableVertices is null)
|
||||
return false;
|
||||
|
||||
WalkableValid = true;
|
||||
WalkablePlane = LastWalkablePlane;
|
||||
WalkableVertices = (Vector3[])LastWalkableVertices.Clone();
|
||||
WalkableUp = LastWalkableUp;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide fallback when step-up fails. Clears the contact-plane state that
|
||||
/// caused the step-up attempt and runs the full sphere-slide computation
|
||||
|
|
@ -458,13 +416,6 @@ public sealed class Transition
|
|||
{
|
||||
var sp = SpherePath;
|
||||
|
||||
// L.4 retail-strict (2026-04-30): clear the kill_velocity flag at
|
||||
// the start of each resolve so leftover state from a prior
|
||||
// transition doesn't carry over. Inside the loop, Phase 3's reset
|
||||
// path may set this via OBJECTINFO::StopVelocity; the engine reads
|
||||
// it after FindTransitionalPosition returns.
|
||||
ObjectInfo.VelocityKilled = false;
|
||||
|
||||
// No starting cell → cannot move.
|
||||
if (sp.CurCellId == 0)
|
||||
return false;
|
||||
|
|
@ -633,9 +584,6 @@ public sealed class Transition
|
|||
// ── Phase 2: object (static BSP + cylinder) collision ───────
|
||||
// Env was OK — now test objects.
|
||||
var objState = FindObjCollisions(engine);
|
||||
// L.4-diag: log Phase outcomes per attempt so we can see whether
|
||||
// we're escaping to the step-down branch or churning in retries.
|
||||
DumpPhase2(attempt, transitState, objState);
|
||||
|
||||
if (objState == TransitionState.Collided)
|
||||
return TransitionState.Collided;
|
||||
|
|
@ -702,50 +650,13 @@ public sealed class Transition
|
|||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
|
||||
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||||
if (diagSteep)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[steep-roof] PHASE3-RESET lastKnownValid={ci.LastKnownContactPlaneValid} " +
|
||||
$"checkPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2}) " +
|
||||
$"curPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2}) " +
|
||||
$"stepUpNormal=({sp.StepUpNormal.X:F2},{sp.StepUpNormal.Y:F2},{sp.StepUpNormal.Z:F2})");
|
||||
}
|
||||
|
||||
// Retail-faithful gate (acclient_2013_pseudo_c.txt:273231-273239):
|
||||
//
|
||||
// if (last_known_valid == 0) {
|
||||
// set_collision_normal(step_up_normal); return COLLIDED;
|
||||
// }
|
||||
// kill_velocity(this);
|
||||
// last_known_valid = 0;
|
||||
// return COLLIDED;
|
||||
//
|
||||
// kill_velocity ONLY fires when last_known was valid. When
|
||||
// it's not (the case our L.2.4 proximity guard produces
|
||||
// after a few airborne frames), velocity is PRESERVED so
|
||||
// the bounce reflection in handle_all_collisions can
|
||||
// redirect V's perpendicular component along the slope's
|
||||
// tangent direction — that's how retail's body escapes
|
||||
// the wedge geometry.
|
||||
//
|
||||
// This was deviated to "unconditional" earlier in this
|
||||
// session as a hypothesis-driven fix; the live trace
|
||||
// showed the deviation CAUSED the wedge by zeroing V
|
||||
// every frame, leaving the body with no tangent momentum
|
||||
// to escape (live diag 2026-04-30: V=(0,0,0) for 169
|
||||
// consecutive frames while position pre/resolved frozen).
|
||||
if (ci.LastKnownContactPlaneValid)
|
||||
{
|
||||
ci.LastKnownContactPlaneValid = false;
|
||||
oi.StopVelocity();
|
||||
if (diagSteep)
|
||||
Console.WriteLine($"[steep-roof] PHASE3-RESET-KILLV ← StopVelocity called");
|
||||
}
|
||||
else
|
||||
{
|
||||
ci.SetCollisionNormal(sp.StepUpNormal);
|
||||
}
|
||||
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
|
@ -764,27 +675,7 @@ public sealed class Transition
|
|||
// as in contact with the ground, but the current CheckPos has no
|
||||
// terrain contact (walked off an edge). Attempt a step-down to
|
||||
// maintain ground contact.
|
||||
//
|
||||
// L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane
|
||||
// IS valid but the surface is too steep to walk on. This is the
|
||||
// "player standing on a steep roof / steep terrain" case. Phase 1
|
||||
// sets ContactPlane on the slope (geometric touch is enough — no
|
||||
// walkable check), so without this clause the step-down branch
|
||||
// skips and EdgeSlideAfterStepDownFailed never gets the chance to
|
||||
// call CliffSlide. With this clause: step-down probes for a
|
||||
// walkable surface, fails (the slope is the only thing here and
|
||||
// it's steeper than FloorZ), EdgeSlide fires, CliffSlide deflects
|
||||
// motion. Then gravity does the rest of the downhill drift.
|
||||
//
|
||||
// Retail's transitional_insert OK-path always runs the step-down
|
||||
// chain (per agent reports of acclient_2013_pseudo_c.txt:273191).
|
||||
// We approximate that by triggering it whenever the current contact
|
||||
// is invalid OR steeper than walkable.
|
||||
bool contactInvalidOrSteep = !ci.ContactPlaneValid
|
||||
|| ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ;
|
||||
// L.4-diag (2026-04-30): trace why we don't slide down roofs.
|
||||
DumpStepDownBranchGate(contactInvalidOrSteep);
|
||||
if (contactInvalidOrSteep && oi.Contact && !sp.StepDown
|
||||
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
|
||||
&& sp.CheckCellId != 0 && oi.StepDown)
|
||||
{
|
||||
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
|
||||
|
|
@ -842,23 +733,7 @@ public sealed class Transition
|
|||
// we are missing precipice context, a steep contact plane, or
|
||||
// merely the EdgeSlide flag.
|
||||
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
|
||||
|
||||
var edgeState = EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
|
||||
if (edgeState == TransitionState.Slid)
|
||||
{
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
sp.NegPolyHit = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edgeState == TransitionState.Adjusted)
|
||||
{
|
||||
sp.NegPolyHit = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
return edgeState;
|
||||
return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
|
||||
}
|
||||
|
||||
return TransitionState.OK;
|
||||
|
|
@ -878,41 +753,10 @@ public sealed class Transition
|
|||
var ci = CollisionInfo;
|
||||
var oi = ObjectInfo;
|
||||
|
||||
// L.4-cliffslide-priority (2026-04-30): the steep-ContactPlane check
|
||||
// moved BEFORE the OnWalkable/EdgeSlide gate.
|
||||
//
|
||||
// Why: by the time this dispatch runs on subsequent frames (player
|
||||
// standing on a steep slope), ValidateTransition's L.2.3i FloorZ
|
||||
// test has already CLEARED OnWalkable (steep slope → not a walkable
|
||||
// surface). The original Branch 1 (`!OnWalkable → restore + OK`)
|
||||
// therefore fires every frame, stopping the player dead — exactly
|
||||
// the "stay on the roof" symptom the user reported.
|
||||
//
|
||||
// Re-ordering: if the surface is too steep AND we have a contact
|
||||
// plane on it, run CliffSlide regardless of OnWalkable. The
|
||||
// cross(currentNormal, lastKnownNormal) deflection plus gravity
|
||||
// produces visible downhill drift each frame.
|
||||
//
|
||||
// Branch 1 (the !OnWalkable stop) still fires when we DON'T have
|
||||
// a contact plane — the original "walked off into thin air"
|
||||
// case, which should still stop or fall normally rather than
|
||||
// CliffSlide on nothing.
|
||||
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide)
|
||||
{
|
||||
var cliffPlane = ci.ContactPlane;
|
||||
DumpEdgeSlideBranch("priority/steep-cliffslide", zVal);
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
return CliffSlide(cliffPlane);
|
||||
}
|
||||
|
||||
// Retail lets non-EdgeSlide movers continue over the boundary. Player
|
||||
// movement carries EdgeSlide, so the local avatar takes the slide path.
|
||||
if (!oi.OnWalkable || !oi.EdgeSlide)
|
||||
{
|
||||
DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal);
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
|
|
@ -923,7 +767,6 @@ public sealed class Transition
|
|||
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal)
|
||||
{
|
||||
var cliffPlane = ci.ContactPlane;
|
||||
DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal);
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
|
|
@ -931,37 +774,8 @@ public sealed class Transition
|
|||
return CliffSlide(cliffPlane);
|
||||
}
|
||||
|
||||
if (!sp.HasWalkablePolygon)
|
||||
sp.RestoreLastWalkable();
|
||||
|
||||
if (sp.HasWalkablePolygon)
|
||||
{
|
||||
// L.4-walkable-steep (2026-04-30): the stored Walkable polygon
|
||||
// can be a too-steep surface (e.g., a roof the player jumped
|
||||
// onto — Path 4's airborne-landing branch uses LandingZ, the
|
||||
// permissive 0.087 threshold, so steep roofs get accepted as
|
||||
// "walkable" for the landing). On subsequent frames the player
|
||||
// is STANDING ON that polygon, not crossing its edge, so
|
||||
// PrecipiceSlide's find_crossed_edge returns false and the
|
||||
// player gets stuck in a Collided revert loop.
|
||||
//
|
||||
// Detect the case: if the walkable polygon's plane is steeper
|
||||
// than FloorZ, route to CliffSlide using that plane instead of
|
||||
// PrecipiceSlide. CliffSlide deflects motion along the ridge
|
||||
// between current-steep and last-known-walkable; gravity then
|
||||
// produces visible downhill drift.
|
||||
if (sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ)
|
||||
{
|
||||
var cliffPlane = sp.WalkablePlane;
|
||||
DumpEdgeSlideBranch("walkable-poly-steep-cliffslide", zVal);
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
return CliffSlide(cliffPlane);
|
||||
}
|
||||
|
||||
DumpEdgeSlideBranch("branch3/precipice-slide", zVal);
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
return sp.PrecipiceSlide(this);
|
||||
|
|
@ -969,7 +783,6 @@ public sealed class Transition
|
|||
|
||||
if (ci.ContactPlaneValid)
|
||||
{
|
||||
DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal);
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
|
|
@ -989,9 +802,6 @@ public sealed class Transition
|
|||
ci.ContactPlaneIsWater = false;
|
||||
sp.RestoreCheckPos();
|
||||
|
||||
if (!sp.HasWalkablePolygon)
|
||||
sp.RestoreLastWalkable();
|
||||
|
||||
if (sp.HasWalkablePolygon)
|
||||
return sp.PrecipiceSlide(this);
|
||||
|
||||
|
|
@ -1004,53 +814,20 @@ public sealed class Transition
|
|||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
||||
// L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane
|
||||
// as the cross-product reference, falling back to world-up when no
|
||||
// walkable history is available. Without this, when the player has
|
||||
// been on a steep slope for >1 frame, ValidateTransition's L.2.3i
|
||||
// FloorZ test propagates the steep plane into LastKnownContactPlane,
|
||||
// so cross(currentSteep, lastKnownSteep) = 0 → degenerate, no
|
||||
// deflection. Using LastWalkable preserves the prior flat-ground
|
||||
// plane across continuous-slope frames; world-up gives a guaranteed
|
||||
// non-zero deflection when no walkable history exists at all.
|
||||
Vector3 referenceNormal;
|
||||
string refSource;
|
||||
if (sp.HasLastWalkablePolygon && sp.LastWalkablePlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||
{
|
||||
referenceNormal = sp.LastWalkablePlane.Normal;
|
||||
refSource = "last-walkable";
|
||||
}
|
||||
else if (ci.LastKnownContactPlaneValid && ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||
{
|
||||
referenceNormal = ci.LastKnownContactPlane.Normal;
|
||||
refSource = "last-known-walkable";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: world up. cross(steepNormal, UnitZ) gives the
|
||||
// ridge direction (horizontal contour line of the slope).
|
||||
// collideNormal then becomes the downhill horizontal axis.
|
||||
referenceNormal = Vector3.UnitZ;
|
||||
refSource = "world-up-fallback";
|
||||
}
|
||||
if (!ci.LastKnownContactPlaneValid)
|
||||
return TransitionState.OK;
|
||||
|
||||
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
|
||||
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal);
|
||||
contactNormal.Z = 0f;
|
||||
|
||||
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
|
||||
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane,
|
||||
new Plane(referenceNormal, 0f), contactNormal, 0f, false);
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
collideNormal = Vector3.Normalize(collideNormal);
|
||||
|
||||
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
|
||||
float angle = Vector3.Dot(collideNormal, offset);
|
||||
DumpCliffSlide($"ok/{refSource}", contactPlane,
|
||||
new Plane(referenceNormal, 0f), collideNormal, angle, true);
|
||||
|
||||
if (angle <= 0f)
|
||||
{
|
||||
|
|
@ -1076,74 +853,7 @@ public sealed class Transition
|
|||
|
||||
Console.WriteLine(
|
||||
System.FormattableString.Invariant(
|
||||
$"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.4-diag: log step-down branch gate decision. Whether we entered or
|
||||
/// skipped the contact-recovery branch matters for whether CliffSlide
|
||||
/// has any chance of firing.
|
||||
/// </summary>
|
||||
private void DumpStepDownBranchGate(bool contactInvalidOrSteep)
|
||||
{
|
||||
if (!DumpEdgeSlideEnabled) return;
|
||||
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
var oi = ObjectInfo;
|
||||
|
||||
bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown
|
||||
&& sp.CheckCellId != 0 && oi.StepDown;
|
||||
|
||||
if (!wouldEnter) return; // only log when entering, to keep noise low
|
||||
|
||||
Console.WriteLine(
|
||||
System.FormattableString.Invariant(
|
||||
$"edge-slide: stepdown-branch-enter cur={Fmt(sp.CurPos)} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} contact={oi.Contact}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.4-diag: log Phase 2 outcome per inner attempt. Tells us whether
|
||||
/// we're churning in Slid retries or escaping to step-down branch.
|
||||
/// </summary>
|
||||
private void DumpPhase2(int attempt, TransitionState envState, TransitionState objState)
|
||||
{
|
||||
if (!DumpEdgeSlideEnabled) return;
|
||||
if (objState == TransitionState.OK) return; // skip clean attempts
|
||||
|
||||
Console.WriteLine(
|
||||
System.FormattableString.Invariant(
|
||||
$"edge-slide: phase2 attempt={attempt} env={envState} obj={objState}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired.
|
||||
/// Tells us whether CliffSlide gets called or whether we hit a
|
||||
/// stop-at-edge branch.
|
||||
/// </summary>
|
||||
private void DumpEdgeSlideBranch(string branch, float zVal)
|
||||
{
|
||||
if (!DumpEdgeSlideEnabled) return;
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
var oi = ObjectInfo;
|
||||
Console.WriteLine(
|
||||
System.FormattableString.Invariant(
|
||||
$"edge-slide: branch={branch} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} lastValid={ci.LastKnownContactPlaneValid} lastN.Z={(ci.LastKnownContactPlaneValid ? ci.LastKnownContactPlane.Normal.Z : 0f):F3} walkPolyValid={sp.HasWalkablePolygon} walkPolyN.Z={(sp.HasWalkablePolygon ? sp.WalkablePlane.Normal.Z : 0f):F3} lastWalkPolyN.Z={(sp.HasLastWalkablePolygon ? sp.LastWalkablePlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} edgeFlag={oi.EdgeSlide} zVal={zVal:F3}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.4-diag: log CliffSlide invocation. Tells us whether the
|
||||
/// cross-product is degenerate (no slide) or producing a real
|
||||
/// deflection.
|
||||
/// </summary>
|
||||
private void DumpCliffSlide(string outcome, Plane current, Plane lastKnown,
|
||||
Vector3 collideNormal, float angle, bool willApply)
|
||||
{
|
||||
if (!DumpEdgeSlideEnabled) return;
|
||||
Console.WriteLine(
|
||||
System.FormattableString.Invariant(
|
||||
$"edge-slide: cliffslide outcome={outcome} curN={Fmt(current.Normal)} lastN={Fmt(lastKnown.Normal)} collideN={Fmt(collideNormal)} angle={angle:F4} apply={willApply}"));
|
||||
$"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
|
||||
}
|
||||
|
||||
private static string Fmt(Vector3 value) =>
|
||||
|
|
@ -1633,18 +1343,7 @@ public sealed class Transition
|
|||
else if (ci.LastKnownContactPlaneValid)
|
||||
contactPlane = ci.LastKnownContactPlane;
|
||||
else
|
||||
{
|
||||
// Airborne wall-only hit: retail normally reaches this with a
|
||||
// LastKnownContactPlane from CPhysicsObj::get_object_info when the
|
||||
// object is still in Contact. Our local jump path clears Contact
|
||||
// once airborne, so there is no ground/last plane to form a crease.
|
||||
// Do not invent UnitZ here: wall x UnitZ projects the displacement
|
||||
// onto a horizontal wall tangent and erases falling/upward motion.
|
||||
float diff = Vector3.Dot(collisionNormal, gDelta);
|
||||
Vector3 offset = -collisionNormal * diff;
|
||||
sp.AddOffsetToCheckPos(offset);
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f);
|
||||
|
||||
// Crease direction = cross(collisionNormal, contactPlane.Normal).
|
||||
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
|
||||
|
|
@ -2151,44 +1850,12 @@ public sealed class Transition
|
|||
// contact is still valid — keep the mover grounded via the
|
||||
// last-known plane. Without this, every wall bump dropped the
|
||||
// player into the falling animation for one frame.
|
||||
//
|
||||
// L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the
|
||||
// last-known plane if the sphere is still actually near it.
|
||||
// Geometrically: `angle` is the signed distance from the
|
||||
// sphere center to the plane. If |angle| exceeds the sphere
|
||||
// radius (plus a tiny epsilon), the sphere has SEPARATED
|
||||
// from the plane — typically because we fell off an edge or
|
||||
// the body dropped vertically while the resolver bounced
|
||||
// through edge-slide attempts. Without this guard the player
|
||||
// gets stuck mid-fall in a falling animation forever (live
|
||||
// bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the
|
||||
// remembered floor, but still being marked Contact + OnWalkable).
|
||||
//
|
||||
// Matches ACE PhysicsObj's pre-reuse check on the last-known
|
||||
// plane and retail's CPhysicsObj::get_object_info logic.
|
||||
var sphereCenter = sp.GlobalSphere[0].Origin;
|
||||
var radius = sp.GlobalSphere[0].Radius;
|
||||
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
|
||||
+ ci.LastKnownContactPlane.D;
|
||||
|
||||
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
|
||||
{
|
||||
// Still close enough to the last-known plane — preserve
|
||||
// grounded state. L.2.3i FloorZ test for OnWalkable.
|
||||
oi.State |= ObjectInfoState.Contact;
|
||||
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||
oi.State |= ObjectInfoState.OnWalkable;
|
||||
else
|
||||
oi.State &= ~ObjectInfoState.OnWalkable;
|
||||
}
|
||||
oi.State |= ObjectInfoState.Contact;
|
||||
// L.2.3i: same FloorZ correction as the live-contact branch.
|
||||
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||
oi.State |= ObjectInfoState.OnWalkable;
|
||||
else
|
||||
{
|
||||
// Sphere has separated from the last-known plane.
|
||||
// Drop the memory and let the body resolve normally
|
||||
// (gravity → next-frame terrain probe → real contact).
|
||||
ci.LastKnownContactPlaneValid = false;
|
||||
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
|
||||
}
|
||||
oi.State &= ~ObjectInfoState.OnWalkable;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -506,85 +506,6 @@ public class BSPStepUpTests
|
|||
"indicates Path 5 recursing through DoStepUp without guard.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.2c regression: an airborne mover jumping/falling into a vertical wall
|
||||
/// must keep its vertical displacement. With no live or last-known contact
|
||||
/// plane, SlideSphere must remove only the component into the wall; inventing
|
||||
/// a flat UnitZ plane projects the displacement onto the wall/floor crease
|
||||
/// and leaves the character stuck in falling animation against the wall.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void D3_AirborneMover_TallWall_PreservesVerticalMotion()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||
|
||||
var from = new Vector3(0.1f, 0f, 2.0f);
|
||||
var to = new Vector3(0.6f, 0f, 1.5f);
|
||||
|
||||
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
|
||||
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
|
||||
|
||||
t.FindTransitionalPosition(engine);
|
||||
|
||||
Assert.True(t.SpherePath.CurPos.Z < from.Z - 0.1f,
|
||||
$"Expected airborne wall-slide to preserve downward motion; " +
|
||||
$"from.Z={from.Z:F3}, CurPos.Z={t.SpherePath.CurPos.Z:F3}");
|
||||
Assert.True(t.SpherePath.CurPos.X <= 0.5f - BSPStepUpFixtures.SphereRadius + PhysicsGlobals.EPSILON * 20f,
|
||||
$"Expected wall to block X penetration; got CurPos.X={t.SpherePath.CurPos.X:F3}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.2c regression: if an airborne wall collision happens in a one-substep
|
||||
/// frame, the collision normal has to survive into the next frame. Retail
|
||||
/// does this with transient_state bit 2 + InitSlidingNormal. Without that,
|
||||
/// every frame replays the same hard stop and the character hangs in falling
|
||||
/// animation until another correction breaks the loop.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = new Vector3(0.25f, 0f, 2.0f),
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
|
||||
var frame1 = engine.ResolveWithTransition(
|
||||
currentPos: body.Position,
|
||||
targetPos: new Vector3(0.36f, 0f, 1.92f),
|
||||
cellId: 0xA9B40001u,
|
||||
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
||||
sphereHeight: 0f,
|
||||
stepUpHeight: 0.04f,
|
||||
stepDownHeight: 0.04f,
|
||||
isOnGround: false,
|
||||
body: body);
|
||||
|
||||
body.Position = frame1.Position;
|
||||
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"First airborne wall hit should cache SlidingNormal for the next frame.");
|
||||
Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
|
||||
|
||||
var frame2 = engine.ResolveWithTransition(
|
||||
currentPos: body.Position,
|
||||
targetPos: body.Position + new Vector3(0.11f, 0f, -0.08f),
|
||||
cellId: 0xA9B40001u,
|
||||
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
||||
sphereHeight: 0f,
|
||||
stepUpHeight: 0.04f,
|
||||
stepDownHeight: 0.04f,
|
||||
isOnGround: false,
|
||||
body: body);
|
||||
|
||||
Assert.True(frame2.Position.Z < frame1.Position.Z - 0.05f,
|
||||
$"Expected cached wall-slide normal to allow falling on frame 2; " +
|
||||
$"frame1.Z={frame1.Position.Z:F3}, frame2.Z={frame2.Position.Z:F3}");
|
||||
Assert.InRange(frame2.Position.X, 0.24f, 0.31f);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -261,52 +261,6 @@ public class PhysicsEngineTests
|
|||
Assert.Equal(50f, result.Position.Z, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithTransition_EdgeSlideAtLoadedTerrainBoundary_PreservesTangentMotion()
|
||||
{
|
||||
var engine = MakeFlatEngine(terrainZ: 50f);
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = new Vector3(191f, 96f, 50f),
|
||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = new Plane(Vector3.UnitZ, -50f),
|
||||
ContactPlaneCellId = 0x003Du,
|
||||
};
|
||||
|
||||
var settled = engine.ResolveWithTransition(
|
||||
currentPos: new Vector3(191f, 96f, 50f),
|
||||
targetPos: new Vector3(191.25f, 96f, 50f),
|
||||
cellId: 0x003Du,
|
||||
sphereRadius: 0.5f,
|
||||
sphereHeight: 1.2f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.EdgeSlide);
|
||||
|
||||
Assert.True(body.WalkablePolygonValid);
|
||||
Assert.NotNull(body.WalkableVertices);
|
||||
|
||||
var result = engine.ResolveWithTransition(
|
||||
currentPos: settled.Position,
|
||||
targetPos: new Vector3(193f, 98f, 50f),
|
||||
cellId: 0x003Du,
|
||||
sphereRadius: 0.5f,
|
||||
sphereHeight: 1.2f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.EdgeSlide);
|
||||
|
||||
Assert.True(result.IsOnGround);
|
||||
Assert.InRange(result.Position.X, 190.75f, 192.0001f);
|
||||
Assert.True(result.Position.Y > 96.2f);
|
||||
Assert.Equal(50f, result.Position.Z, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
"""Check an .exe's CodeView debug info to see what PDB GUID + age it
|
||||
expects. Used to verify whether a candidate acclient.exe matches our
|
||||
acclient.pdb without running the binary.
|
||||
|
||||
Usage:
|
||||
py tools/pdb-extract/check_exe_pdb.py <path-to-exe>
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: check_exe_pdb.py <path-to-exe>")
|
||||
sys.exit(1)
|
||||
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
# DOS header -> e_lfanew @ offset 0x3C points to PE header
|
||||
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
assert data[pe_off:pe_off + 4] == b"PE\x00\x00", "not a PE file"
|
||||
|
||||
# COFF header
|
||||
machine = struct.unpack_from("<H", data, pe_off + 4)[0]
|
||||
n_sections = struct.unpack_from("<H", data, pe_off + 6)[0]
|
||||
timestamp = struct.unpack_from("<I", data, pe_off + 8)[0]
|
||||
opt_size = struct.unpack_from("<H", data, pe_off + 20)[0]
|
||||
|
||||
print(f"machine = 0x{machine:04x}")
|
||||
print(f"timestamp = 0x{timestamp:08x} ({timestamp})")
|
||||
|
||||
import datetime
|
||||
ts = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
|
||||
print(f" -> linker UTC: {ts.isoformat()}")
|
||||
|
||||
# Optional header — magic indicates 32 vs 64 bit
|
||||
opt_off = pe_off + 24
|
||||
magic = struct.unpack_from("<H", data, opt_off)[0]
|
||||
is_pe32_plus = (magic == 0x20B)
|
||||
print(f"opt magic = 0x{magic:04x} ({'PE32+' if is_pe32_plus else 'PE32'})")
|
||||
|
||||
# Data directories: PE32 has them at opt_off + 96; PE32+ at opt_off + 112
|
||||
dd_off = opt_off + (112 if is_pe32_plus else 96)
|
||||
# Debug directory is data dir [6]
|
||||
debug_va = struct.unpack_from("<I", data, dd_off + 6 * 8)[0]
|
||||
debug_size = struct.unpack_from("<I", data, dd_off + 6 * 8 + 4)[0]
|
||||
print(f"debug dir = VA=0x{debug_va:08x} size={debug_size}")
|
||||
|
||||
# We need to map the VA back to a file offset via section headers
|
||||
sec_off = opt_off + opt_size
|
||||
sections = []
|
||||
for i in range(n_sections):
|
||||
s = sec_off + i * 40
|
||||
name = data[s:s + 8].rstrip(b"\x00").decode("ascii", errors="replace")
|
||||
vsize = struct.unpack_from("<I", data, s + 8)[0]
|
||||
vaddr = struct.unpack_from("<I", data, s + 12)[0]
|
||||
rsize = struct.unpack_from("<I", data, s + 16)[0]
|
||||
roff = struct.unpack_from("<I", data, s + 20)[0]
|
||||
sections.append((name, vaddr, vsize, roff, rsize))
|
||||
|
||||
def va_to_file(va):
|
||||
for (name, vaddr, vsize, roff, rsize) in sections:
|
||||
if vaddr <= va < vaddr + vsize:
|
||||
return roff + (va - vaddr)
|
||||
return None
|
||||
|
||||
debug_off = va_to_file(debug_va)
|
||||
if debug_off is None:
|
||||
print("debug directory VA does not map into any section")
|
||||
return
|
||||
|
||||
# Each debug directory entry is 28 bytes
|
||||
n_entries = debug_size // 28
|
||||
print(f"# debug entries = {n_entries}")
|
||||
|
||||
for i in range(n_entries):
|
||||
e = debug_off + i * 28
|
||||
characteristics = struct.unpack_from("<I", data, e)[0]
|
||||
ts_e = struct.unpack_from("<I", data, e + 4)[0]
|
||||
major = struct.unpack_from("<H", data, e + 8)[0]
|
||||
minor = struct.unpack_from("<H", data, e + 10)[0]
|
||||
type_e = struct.unpack_from("<I", data, e + 12)[0]
|
||||
sz = struct.unpack_from("<I", data, e + 16)[0]
|
||||
rva = struct.unpack_from("<I", data, e + 20)[0]
|
||||
ptr = struct.unpack_from("<I", data, e + 24)[0]
|
||||
|
||||
type_name = {2: "CODEVIEW", 4: "MISC", 12: "VC_FEATURE", 13: "POGO", 16: "REPRO"}.get(type_e, f"type_{type_e}")
|
||||
print(f" entry {i}: type={type_name} sz={sz} fileOff=0x{ptr:08x}")
|
||||
|
||||
if type_e == 2 and sz >= 24:
|
||||
cv = data[ptr:ptr + sz]
|
||||
sig = cv[:4]
|
||||
print(f" cv signature = {sig!r}")
|
||||
if sig == b"RSDS":
|
||||
guid_bytes = cv[4:20]
|
||||
age = struct.unpack_from("<I", cv, 20)[0]
|
||||
pdb_name = cv[24:].rstrip(b"\x00").decode("utf-8", errors="replace")
|
||||
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
|
||||
print(f" GUID = {{{pdb_guid}}}")
|
||||
print(f" age = {age}")
|
||||
print(f" PDB filename = {pdb_name}")
|
||||
|
||||
expected_guid = uuid.UUID("9e847e2f-777c-4bd9-886c-22256bb87f32")
|
||||
expected_age = 1
|
||||
if pdb_guid == expected_guid and age == expected_age:
|
||||
print()
|
||||
print("=== MATCH: this exe pairs with our acclient.pdb ===")
|
||||
else:
|
||||
print()
|
||||
print("=== MISMATCH ===")
|
||||
print(f" expected GUID = {{{expected_guid}}}")
|
||||
print(f" expected age = {expected_age}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""Dump the PDB info stream so we know exactly which acclient.exe build
|
||||
matches our PDB GUID. The PDB header points to stream 1 ("PDB Info") which
|
||||
contains: u32 version, u32 signature(timestamp), u32 age, 16-byte GUID.
|
||||
|
||||
Usage:
|
||||
py tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
def _ceil_div(a, b):
|
||||
return (a + b - 1) // b
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: dump_pdb_info.py <path-to-pdb>")
|
||||
sys.exit(1)
|
||||
|
||||
pdb_path = sys.argv[1]
|
||||
with open(pdb_path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
magic = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\x00\x00\x00"
|
||||
assert data.startswith(magic), "not an MSF 7.00 PDB"
|
||||
|
||||
block_size = struct.unpack_from("<I", data, 0x20)[0]
|
||||
num_blocks = struct.unpack_from("<I", data, 0x28)[0]
|
||||
num_dir_bytes = struct.unpack_from("<I", data, 0x2C)[0]
|
||||
block_map_addr = struct.unpack_from("<I", data, 0x34)[0]
|
||||
|
||||
print(f"block_size = {block_size}")
|
||||
print(f"num_blocks = {num_blocks}")
|
||||
print(f"num_dir_bytes = {num_dir_bytes}")
|
||||
print(f"block_map_addr = {block_map_addr}")
|
||||
|
||||
def read_page(idx):
|
||||
return data[idx * block_size : (idx + 1) * block_size]
|
||||
|
||||
dir_pages_needed = _ceil_div(num_dir_bytes, block_size)
|
||||
block_map = read_page(block_map_addr)
|
||||
dir_page_indices = struct.unpack_from(f"<{dir_pages_needed}I", block_map, 0)
|
||||
dir_data = bytearray()
|
||||
for pi in dir_page_indices:
|
||||
dir_data.extend(read_page(pi))
|
||||
dir_data = bytes(dir_data)
|
||||
|
||||
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
|
||||
stream_sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
|
||||
print(f"num_streams = {num_streams}")
|
||||
|
||||
offset = 4 + num_streams * 4
|
||||
streams = []
|
||||
for sz in stream_sizes:
|
||||
if sz == 0xFFFFFFFF:
|
||||
streams.append((0, []))
|
||||
continue
|
||||
n_pages = _ceil_div(sz, block_size)
|
||||
pages = struct.unpack_from(f"<{n_pages}I", dir_data, offset)
|
||||
offset += n_pages * 4
|
||||
streams.append((sz, list(pages)))
|
||||
|
||||
# Stream 1 = PDB Info Stream
|
||||
pdb_info_size, pdb_info_pages = streams[1]
|
||||
print(f"pdb_info_size = {pdb_info_size}")
|
||||
|
||||
pdb_info = bytearray()
|
||||
for pi in pdb_info_pages:
|
||||
pdb_info.extend(read_page(pi))
|
||||
pdb_info = bytes(pdb_info[:pdb_info_size])
|
||||
|
||||
version = struct.unpack_from("<I", pdb_info, 0)[0]
|
||||
signature = struct.unpack_from("<I", pdb_info, 4)[0]
|
||||
age = struct.unpack_from("<I", pdb_info, 8)[0]
|
||||
guid_bytes = pdb_info[12:28]
|
||||
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
|
||||
|
||||
sig_dt = datetime.datetime.fromtimestamp(signature, tz=datetime.timezone.utc)
|
||||
|
||||
print()
|
||||
print("=== PDB Info Stream ===")
|
||||
print(f"version = {version}")
|
||||
print(f"signature = 0x{signature:08x} ({signature})")
|
||||
print(f" -> linker timestamp UTC: {sig_dt.isoformat()}")
|
||||
print(f"age = {age}")
|
||||
print(f"GUID = {{{pdb_guid}}}")
|
||||
print()
|
||||
print("This is the GUID + age the matching acclient.exe must reference")
|
||||
print("in its CodeView entry. Find a binary whose linker timestamp")
|
||||
print(f"is around {sig_dt.strftime('%Y-%m-%d')}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue