Commit graph

475 commits

Author SHA1 Message Date
Erik
4ad7a985cf phase(N.4) Task 9: real WB pipeline bring-up + InstancedMeshRenderer routing
WbMeshAdapter now actually constructs the WB pipeline:
- OpenGLGraphicsDevice(gl, logger, DebugRenderSettings)
- DefaultDatReaderWriter(datDir) — opens its own file handles for now
  (memory cost ~50-100MB of duplicate index caches, acceptable for
  foundation work per plan Adjustment 1)
- ObjectMeshManager(graphicsDevice, dats, NullLogger)

InstancedMeshRenderer.EnsureUploaded routes through the adapter when
ACDREAM_USE_WB_FOUNDATION=1 is set; uses a WbManagedSentinel entry
in the local cache to mark "this GfxObj lives in WB now". CollectGroups
skips sentinel entries; both Draw passes skip them; Dispose skips them
(no GL resources to free — ObjectMeshManager owns those). Task 22's
WbDrawDispatcher will eventually draw WB-managed objects. With flag
off, behavior is byte-identical to before.

WbMeshAdapter constructor signature changed from (GL, DatCollection,
Logger) to (GL, string datDir, Logger). Updated tests to use
CreateUninitialized() for behavior tests and single null-GL guard test
for constructor validation. GameWindow updated to pass _datDir and to
wire _wbMeshAdapter into InstancedMeshRenderer.

AcDream.App.csproj gets direct ProjectReferences to WorldBuilder.Shared
and Chorizite.OpenGLSDLBackend — project refs are not transitive in
.NET, so AcDream.App must list them explicitly even though AcDream.Core
already references them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:31:30 +02:00
Erik
502c3a87e4 phase(N.4) Tasks 6+7: skip dat-reader bridge; wire WbMeshAdapter into GameWindow
Task 6 (dat-reader bridge) obsoleted: WB ships DefaultDatReaderWriter
which takes a dat-directory path and constructs all four databases
(Portal/HighRes/Language + CellRegions) internally. We can use it
directly instead of bridging our DatCollection. Adjustment 1 noted
in the plan; full bring-up deferred to Task 9.

Task 7: GameWindow constructs WbMeshAdapter when
ACDREAM_USE_WB_FOUNDATION=1 is set; pairs with Dispose. Field is
null when flag is off, so no behavioral effect on default-off path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:47 +02:00
Erik
1030c69b3c phase(N.4): WbMeshAdapter stub + IWbMeshAdapter interface
Stub adapter that validates constructor args and exposes the public
shape (IncrementRefCount / DecrementRefCount / GetRenderData / Dispose).
Real ObjectMeshManager init is deferred to Task 9 — for now methods
no-op so call sites can wire the adapter without behavioral effect.

IWbMeshAdapter interface enables mocking in subsequent tasks
(LandblockSpawnAdapter tests in Task 11 need it).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:18:50 +02:00
Erik
46deed6019 phase(N.4): AcSurfaceMetadata side-table for WB-pristine surface props
Holds Translucency / Luminosity / Diffuse / SurfOpacity / NeedsUvRepeat /
DisableFog keyed by (gfxObjId, surfaceIdx). Populated at extraction time,
queried by the draw dispatcher. ConcurrentDictionary because mesh
extraction happens on background workers.

No fork patches required — keeps WB's MeshBatchData pristine.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:08:56 +02:00
Erik
81b5ed8c68 phase(N.4): WbFoundationFlag scaffold for ACDREAM_USE_WB_FOUNDATION env var
Creates the src/AcDream.App/Rendering/Wb/ folder and the static flag
gate that other call sites will import. Read once at static-init time.
Set ACDREAM_USE_WB_FOUNDATION=1 to enable WB foundation routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:06:12 +02:00
Erik
0a67254c5e refactor(N.3): thread isAdditive + substitute 5 decode methods with WB TextureHelpers
Task 2 — isAdditive threading:
SurfaceDecoder.DecodeRenderSurface now accepts isAdditive parameter.
A8/CUSTOM_LSCAPE_ALPHA format splits:
- isAdditive=true:  R=G=B=A=val (terrain alpha, additive entity textures)
- isAdditive=false: R=G=B=255, A=val (non-additive entity textures)
TextureCache passes surface.Type.HasFlag(SurfaceType.Additive).
TerrainAtlas passes isAdditive:true (alpha masks always replicate).
Aligns with WB ObjectMeshManager dispatch logic.

Task 3 — WB body substitution + new formats:
INDEX16, P8, A8R8G8B8, R8G8B8, A8 now delegate to
TextureHelpers.FillIndex16/FillP8/FillA8R8G8B8/FillR8G8B8/
FillA8/FillA8Additive. Validation + DecodedTexture wrapping stays ours.
X8R8G8B8, DXT1/3/5, SolidColor remain our implementations (no WB equiv).

Bonus: R5G6B5 + A4R4G4B4 formats now handled (previously fell to magenta).

9 conformance tests pass. Build 0 errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:32:37 +02:00
Erik
b0ec6deb50 phase(N.1): delete legacy scenery code path; WB is the only path
Phase N.1 step 8 (final code cleanup): now that ACDREAM_USE_WB_SCENERY
has been default-on (commit b84ecbd), remove the legacy in-line
algorithms so we don't accumulate dead-code drift.

Deleted:
- SceneryGenerator.UseWbScenery (feature flag)
- SceneryGenerator.IsOnRoad / DisplaceObject / RoadHalfWidth (legacy
  ports — Generate used to call them)
- The legacy in-line implementation in Generate()
- SceneryGeneratorTests.DisplaceObject_* (test the deleted method)
- SceneryWbConformanceTests.cs entirely (purpose served — proved
  equivalence pre-migration; would compare WB to WB after delete)

Renamed:
- GenerateViaWb -> GenerateInternal (it's the only path now)

Kept:
- Public IsRoadVertex predicate (small surface, useful)
- WbSceneryAdapter (consumed by GenerateInternal)
- All WbSceneryAdapterTests (still cover the adapter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:37:55 +02:00
Erik
b84ecbda51 phase(N.1): WB-backed scenery is now default-on
Phase N.1 step 7: flips ACDREAM_USE_WB_SCENERY to default-on after
visual verification at Holtburg confirmed Issue #49's previously
missing edge-vertex trees are still visible and rotation is correct.

A known cosmetic difference (the road-edge tree at landblock 0xA9B1)
remains. ACME WorldBuilder applies an additional per-vertex road
check that suppresses it; we tried adding it (commit e279c46) but
it over-suppressed in other landblocks (reverted in 677a726). Filed
as a follow-up issue in ISSUES.md (added in Task 8).

ACDREAM_USE_WB_SCENERY=0 still reverts to the legacy path. Task 8
will delete the legacy path entirely once a session passes without
visual regressions on default-on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:31:55 +02:00
Erik
677a726e61 Revert "phase(N.1): add ACME-conformant per-vertex road check"
This reverts commit e279c46aac.
2026-05-08 10:26:37 +02:00
Erik
e279c46aac phase(N.1): add ACME-conformant per-vertex road check
Phase N.1 hotfix: scenery near a road still rendered in acdream
even with WB-backed generation. Investigation (worktree session
2026-05-08) showed ACME WorldBuilder skips the entire vertex when
its road bit is set, before any per-object spawn rolls. ACME line:
  references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074
  if (entry.Road != 0) continue;

This check was previously REMOVED in commit 833d167 with a comment
claiming retail doesn't have it. The comment was wrong: ACME mirrors
retail and keeps the check, and the upstream Chorizite/WorldBuilder
we forked omits it (which is why our newly-WB-backed Generate path
still produced the bad tree). Adding back to both Generate (legacy)
and GenerateViaWb (WB-backed) for parity.

This does NOT regress #49: the 9x9 loop expansion that recovered
missing edge-vertex scenery is unchanged. Only vertices whose own
road bit is set are now skipped -- same as ACME.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:23:53 +02:00
Erik
ecf4fe9f10 phase(N.1): wire ACDREAM_USE_WB_SCENERY dispatch in Generate()
Phase N.1 step 5: when the flag is set, Generate() delegates to
GenerateViaWb. Default off; flag flips to default-on in step 7
after visual verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:58:20 +02:00
Erik
804bfbb819 phase(N.1): implement GenerateViaWb alternative path
Phase N.1 step 4: parallel implementation of Generate() that calls
WB's SceneryHelpers (Displace/CheckSlope/RotateObj/ObjAlign/ScaleObj)
and TerrainUtils (OnRoad/GetNormal) instead of the inline ports.

Not yet wired in — Generate() still runs the legacy path. Step 5
adds the dispatch.

Per-helper conformance tests in step 3 prove the Displace/OnRoad/
GetNormalZ/ScaleObj substitutions are behavior-equivalent. Rotation
is intentionally NOT conformance-tested because our existing port
diverges from retail by ~180°; WB's RotateObj fixes that bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:56:13 +02:00
Erik
4bfcb2b190 phase(N.1): per-helper conformance tests for WB substitutions (rotation excluded)
Phase N.1 step 3: prove our inline algorithms match WorldBuilder's
helpers for representative inputs including the 0xA9B1 edge-vertex case.

Four conformance tests pass: Displace, OnRoad, GetNormalZ, ScaleObj.
Our hand-ported algorithms match WB's helpers exactly for these.

Rotation is intentionally NOT conformance-tested. Investigation against
retail's Frame::set_heading (named-retail 0x00535e40) and
Frame::set_vector_heading (0x00535db0) showed our acdream port uses a
shortcut formula `yawDeg = -(450-degrees)%360` that diverges from
retail's atan2 round-trip by ~180°. WorldBuilder's SetHeading ports
the round-trip faithfully and matches retail. Our existing port is
wrong — undetectable visually because per-tree rotation noise masks
the offset. The migration to WB.SceneryHelpers.RotateObj fixes this
bug; adding a conformance test would lock in the wrong behavior.

Bumps IsOnRoad to internal so the OnRoad conformance test can call it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:53:00 +02:00
Erik
bbc618a40a phase(N.1): add ACDREAM_USE_WB_SCENERY feature flag scaffold
Phase N.1 step 2: read the env var into a static bool. No behavior
change yet — the flag is consumed in step 5 when GenerateViaWb is
wired in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:22:23 +02:00
Erik
91fd9de3f6 phase(N.1): document LandBlock length-81 invariant on adapter
Addresses code-review feedback on commit 26cf2b8. The dropped
ArgumentException length guards were correct to drop because
DatReaderWriter.LandBlock self-initializes Terrain[] and Height[]
to length 81 in its constructor — but that invariant was not
documented anywhere visible to future readers. Adds an XML doc
<remarks> block explaining the guarantee so callers constructing
synthetic LandBlocks know what to expect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:20:53 +02:00
Erik
26cf2b84e7 phase(N.1): add LandBlock → TerrainEntry[] adapter
Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our
LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's
TerrainUtils / SceneryRenderManager consume.

Field mapping (TerrainInfo → TerrainEntry):
  TerrainInfo.Road    (bits 0-1)   → TerrainEntry.Road
  TerrainInfo.Type    (bits 2-6)   → TerrainEntry.Type
  TerrainInfo.Scenery (bits 11-15) → TerrainEntry.Scenery
  LandBlock.Height[i]              → TerrainEntry.Height

The spec listed the texture property as 'Texture' but TerrainEntry's
actual property is named 'Type' (confirmed from source). The spec also
described LandBlock.Terrain as ushort[81] but it is TerrainInfo[81] —
DatReaderWriter already decodes the bit fields so the adapter uses
TerrainInfo's named properties rather than raw bit-shift expressions.

Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:11:59 +02:00
Erik
c8782c9365 phase(N.0): wire up WorldBuilder fork as submodule + project refs
Phase N.0 setup for the WorldBuilder migration. Replaces the local
read-only clone of Chorizite/WorldBuilder at references/WorldBuilder/
with a git submodule pointing at our fork
(github.com/eriknihlen/WorldBuilder.git, branch acdream).

Changes:
- .gitignore: exempt references/WorldBuilder from the references/
  ignore rule so the submodule can be tracked.
- .gitmodules (new): submodule entry tracking acdream branch on fork.
- src/AcDream.Core/AcDream.Core.csproj: add ProjectReference to
  WorldBuilder.Shared and Chorizite.OpenGLSDLBackend so we can call
  TerrainUtils, SceneryHelpers, etc. from our Core code.

Build green, all 93 scenery/terrain tests pass. The 8 pre-existing
DispatcherToMovement test failures are unrelated and exist on main.

Notes for users picking up this branch on main:
- After merge, the existing local clone at references/WorldBuilder
  may need to be removed before `git submodule update --init` will
  populate the submodule.
- Working on the fork happens via `cd references/WorldBuilder && git
  checkout acdream && <changes> && git push`. To pull upstream
  Chorizite/WorldBuilder fixes: `git remote add upstream
  https://github.com/Chorizite/WorldBuilder.git && git fetch upstream
  && git merge upstream/master`.

Next: Phase N.1 — replace SceneryGenerator algorithm calls with
WB's SceneryHelpers + TerrainUtils.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:51:49 +02:00
Erik
e3c36b5bf8 revert: remove obj_within_block — sorting sphere radii too large
The obj_within_block check using Setup.SortingSphere.Radius rejects
far too many spawns. Sorting spheres for trees are 5-10m, creating
a wide exclusion zone around every landblock edge. WorldBuilder
produces correct scenery with just bounds+road+building+slope checks
and no bounding sphere check. Revert to match WorldBuilder's approach.

The single extra tree near the road at vtx=(4,8) in 0xA9B1 remains
as a known minor discrepancy from retail — root cause TBD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 07:53:04 +02:00
Erik
e8aa1c82f4 fix(scenery): add retail obj_within_block check for edge-boundary spawns
Retail's CLandBlock::get_land_scenes creates a PhysicsObj for each
scenery spawn, then calls CPhysicsObj::obj_within_block (0x00461c30)
which verifies the model's sorting sphere stays within [r, 192-r] on
both axes. Edge-vertex spawns displaced close to the boundary (e.g.,
a tree at Y=190.97 from vertex y=8) get rejected because their sorting
sphere extends past the landblock edge.

We were missing this check, which caused a tree near a road at
~(85, 191) in landblock 0xA9B1 to appear in acdream but not retail.
The tree legitimately passed all other filters (road, building, slope)
but its Setup sorting sphere radius (~2-5m) meant it overflowed the
boundary.

Fix: look up each unique Setup's SortingSphere.Radius from the dat
(cached per objectId) and apply the within-block bounds check after
the slope filter, matching retail's order. GfxObj objects (0x01) use
radius 0 (permissive) since they're typically small single-mesh items.

Also: remove temporary ACDREAM_SCENERY_DIAG logging, fix duplicate
xmldoc on IsOnRoad, update road check reference to named-retail PDB
symbol (CLandBlock::on_road at 0x0052FFF0).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 07:44:17 +02:00
Erik
833d167ebc fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 21:15:11 +02:00
Erik
a4693954d8 fix(scenery): #48 unify scenery Z with physics-path triangle picker
Closes #48. Trees on sloped cells visibly hovered above the visible
terrain because GameWindow.SampleTerrainZ (the bilinear fallback used
during scenery hydration before physics registers a landblock) had
its diagonal arms swapped — used the SEtoNW triangle test on SWtoNE
cells and vice versa. The ACDREAM_DUMP_SCENERY_Z=1 diagnostic showed
every scenery line ran through the bilinear path (streaming race),
so on hilly terrain scenery was placed at a Z up to ~1.5 m off from
the visible mesh.

Latent since ff325ab (2026-04-17 "feat(ui): debug overlay + refined
input controls" carrying along the upgrade). That commit reached for
WorldBuilder TerrainUtils.GetHeight as the secondary oracle and
re-derived the triangle-pair tests; the named-retail / ACE algorithm
in TerrainSurface.SampleZ (used by the physics path / player Z) was
always correct, so player feet stayed flush — the two paths just
disagreed and only scenery noticed.

Fix:
- TerrainSurface.InterpolateZInTriangle (private static) — single
  source of truth for the triangle pick + barycentric Z, sourced
  from FUN_00532a50 / ACE LandblockStruct.ConstructPolygons.
- TerrainSurface.SampleZFromHeightmap (public static) — heightmap-
  byte-array variant for the scenery hydration fallback. Both this
  and TerrainSurface.SampleZ (instance) now delegate to the same
  InterpolateZInTriangle.
- GameWindow.SampleTerrainZ — thin wrapper over the new static.
- TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock
  asserts both sampler paths agree at 1500 sample points across both
  diagonals, so future drift gets caught.

The ACDREAM_DUMP_SCENERY_Z=1 diagnostic in BuildSceneryEntitiesForStreaming
is kept committed (env-var gated, zero cost when off) — useful for
the related #49 scenery (X, Y) placement investigation filed in the
same commit.

Visual verified at Holtburg landblock 0xA9B30001 2026-05-06: the
formerly floating 32 m pines (setups 0x020002D3 / 0x020002D9) now
sit flush on the visible terrain mesh.

Test baseline: dotnet test reports the same 8 pre-existing motion /
BSP step-up failures as the handoff doc warned about — no new
failures introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:30:25 +02:00
Erik
c1bb43ab89 Merge main into claude/strange-ardinghelli-d810cd
Brings in #38 render-interpolation camera work before testing #48
diagnostic dump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:58:06 +02:00
Erik
8ee76deefd diag(scenery): #48 ACDREAM_DUMP_SCENERY_Z dump
Per-spawn / per-rendered-mesh log line at scenery hydration: rendered
gfx id, sample source (physics vs bilinear), groundZ, BaseLoc.Z,
finalZ, mesh vertex Z range, and DIDDegrade slot 0 metadata. One log
line lets the user identify a floating tree by world coords and the
data picks the hypothesis (BaseLoc.Z addition / sampler drift /
DIDDegrade selection). Diagnostic-first per CLAUDE.md before the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:57:10 +02:00
Erik
71b1622293 fix(camera): #38 render-interpolate player motion
Keep local physics authoritative at the retail 30 Hz MinQuantum, but expose a render-only position that lerps between completed physics ticks for the player mesh and chase-camera target. Network outbound continues to use the discrete physics position.

Also make the visually confirmed #47 humanoid close-detail DIDDegrade path default-on, with ACDREAM_RETAIL_CLOSE_DEGRADES=0 left as a diagnostic opt-out.

Verification: dotnet build AcDream.slnx -c Debug; focused #38 interpolation tests passed; visual confirmed smooth 2026-05-06. Full dotnet test AcDream.slnx -c Debug --no-build still has the known 8 AcDream.Core.Tests baseline failures.

Co-authored-by: Codex <codex@openai.com>
2026-05-06 17:53:34 +02:00
Erik
0bd9b9693b fix(rendering): #47 — walk DIDDegrade for retail close-detail meshes
Humanoid bodies (Setup 0x02000001 + heritage variants) rendered visibly
flat / bulky vs retail because we drew the base GfxObj id from Setup /
AnimPartChange directly. Retail's CPhysicsPart::LoadGfxObjArray
(0x0050DCF0) treats that base id as the entry point to a DIDDegrade
table; close/player rendering uses Degrades[0].Id, which is the
higher-detail mesh that carries bicep / deltoid / shoulder geometry.

ACViewer also has this bug — it was the key signal it isn't acdream-
specific. Both clients drew the LOD-3 base mesh (e.g. 14 verts / 17
polys for Aluvian Male upper arm 0x01000055), missing the close-
detail variant (0x01001795: 32 verts / 60 polys).

Adds GfxObjDegradeResolver that walks the table with safe fallbacks
at every step. Wired in GameWindow after AnimPartChange application
and before texture-change resolution so texture overrides match the
resolved mesh's surfaces. Gated by ACDREAM_RETAIL_CLOSE_DEGRADES=1
and scoped to humanoid setups (34 parts with >=8 null-sentinel
attachment slots) while the fix bakes — the change is harmless on
non-humanoid setups (resolver falls back to base when no degrade
table) but we hold the broader sweep until LOD distance plumbing
lands.

User confirmed visually 2026-05-06: bicep, deltoid, and back-muscle
definition match retail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:46:23 +02:00
Erik
e697a9ad1e docs(issues): file #47 (humanoid bulky-shape bug); land DUMP_CLOTHING diagnostics
Filed #47 in docs/ISSUES.md — humanoid characters using Setup 0x02000001
(players + Woodsman + other Aluvian NPCs) render visibly bulkier and less
shape-defined than retail's view. Drudges and other monster setups render
identically. Independent of equipment (naked +Je still shows it).
Investigation this session ruled out 0xF625 ObjDescEvent drops (real bug,
fixed in e471527, but doesn't explain shape), HiddenParts overlap,
ParentIndex walking (animation frames are setup-root coords already),
and player-specific data flow (NPCs using same setup affected too).

Diagnostic infrastructure landed alongside the issue (env-var-gated, no
runtime cost when off):
- ACDREAM_DUMP_CLOTHING=1 now also prints:
  - setup.Parts.Count, flatten.Count, APC count on header
  - ParentIndex[] and DefaultScale[] arrays
  - IdleFrame per-part Origin + Orientation (first 17 parts)
  - per-part EMIT line: gfx, subMeshes count, triangle count
  - TOTAL triangle / meshRef counts per entity
This is what nailed down "all 34 parts emit" + "animation frames are
setup-root not parent-local" + "humans get setup-wide 180°-Z rotation
that drudges don't" — saved hours next session.

Open hypotheses for #47 next session: per-face vs smoothed vertex
normals (per-vertex normals from dat may be face-style for human
GfxObjs but smooth for monsters), low cell ambient leaving back faces
flat-shadowed, missing MSAA on the GL window.
2026-05-06 11:30:41 +02:00
Erik
e471527924 feat(net): wire 0xF625 ObjDescEvent for live appearance updates
Retail-driven players observed from acdream rendered with stale
appearance — wrong skin/hair palettes, missing clothing — because
ACE's mid-session appearance broadcasts (equip/unequip/tailoring/
recipe/option-toggle) ride opcode 0xF625 ObjDescEvent and acdream
silently dropped them. Initial CreateObject carries the appearance
at spawn time, but every later equip change only updates via 0xF625
(per Skunkwors protocol docs in ACE/.../GameMessageObjDescEvent.cs).
Retail handles via SmartBox::HandleObjDescEvent (named-retail 0x453340).

Why: the retail observer sees the *server-relayed* view of remotes,
not retail's local build, so dropping ObjDescEvent freezes appearance
at the partial state in the first CreateObject.

How:
- Extract CreateObject's ModelData parsing into reusable
  CreateObject.ReadModelData(span, ref pos) returning
  (BasePaletteId, SubPalettes, TextureChanges, AnimPartChanges).
- Add ObjDescEvent.cs (parser for 0xF625):
  body = u32 opcode | u32 guid | ModelData | u32 instanceSeq | u32 visualDescSeq.
- WorldSession.AppearanceUpdated event + dispatcher branch.
- GameWindow.OnLiveAppearanceUpdated splices new ModelData onto the
  cached spawn and replays via OnLiveEntitySpawned. The dedup at the
  start of OnLiveEntitySpawnedLocked tears down the old GPU/animated/
  collision state cleanly before rebuild.
- _lastSpawnByGuid cache populated at spawn-end and tracked through
  UpdatePosition so re-applies use current position (no pop-back to
  login spot on equip toggle).
- ACDREAM_DUMP_APPEARANCE=1 env var prints structured SP/TC/APC
  decode for every 0xF625 — replaces the earlier raw-hex preview.
- ACDREAM_DUMP_CLOTHING extended with setup.Parts.Count, flatten.Count,
  and per-part triangle counts for offline polygon-budget audit.

Tests: 4 new ObjDescEvent tests (round-trip + parser drift guard);
269 net tests green. User-verified live: skin/hair colors match
retail's character data; equip/unequip no longer pops position.

Note: a separate "puffy arms / bulky body" geometry issue remains
where base body parts visibly overlap clothing meshes — different
root cause, tracked separately.
2026-05-06 10:46:14 +02:00
Erik
e9e080db8c fix(motion): close #45 — scale local sidestep speedMod by ACE's wire factor
User observed (during fix #5 visual verify of #39): "our own Acdream
client renders sidestep walking too slow". Filed as #45.

Root cause: PlayerMovementController.cs:871 computes localAnimSpeed as
the raw `runRate || 1.0`, while ACE's BroadcastMovement converts
inbound MoveToState SidestepSpeed via
    speed × 3.12 / 1.25 × 0.5
(Network/Motion/MovementData.cs:124-131). Observer-side cycles play at
the ACE-scaled value (~1.248 slow / ~3.0 fast clamped); the local
cycle was playing at the raw 1.0 / runRate — about 80% of retail
cadence for slow strafe.

Fix: in UpdatePlayerAnimation, when animCommand is SideStepLeft / Right
(low byte 0x0F or 0x10), multiply animSpeed by
    WalkAnimSpeed / SidestepAnimSpeed × 0.5 = 3.12 / 1.25 × 0.5 = 1.248
before calling SetCycle. Same factor as ACE; no clamp on the local
side (sequencer handles MultiplyCyclicFramerate naturally).

Forward / backward / turn cycles unchanged — those use WalkAnimSpeed
or RunAnimSpeed as base, where localAnimSpeed = wire ForwardSpeed
already produces the right cadence.

Build clean. Visual verify pending: user reports slow-strafe cadence
should match retail / our own observed-remote rendering after this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:58:41 +02:00
Erik
898d7cd2cf tune(motion): #39 — tighten UmGraceSeconds 0.5 → 0.2
User observed the Shift-toggle cycle transition was "not as fast as
retail" after fixes #3-#5 landed the velocity-fallback path. Worst-case
added latency was the full 500 ms grace window before the first UP
could refine the cycle.

200 ms covers the actual UM/UP race — UMs arrive on direction-key
events, UPs at 5-10 Hz, so the first UP after a fresh UM lands
~100-200 ms behind it. Below that, fallback could prematurely
overwrite a UM's cycle decision; above that adds latency for no
correctness benefit. Direction flips (W↔S, A↔D, Forward↔Strafe)
update via UM directly so they're unaffected by this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:50:44 +02:00
Erik
349ba65f3e fix(motion): #39 — use SidestepAnimSpeed (1.25) as sidestep mapping base
Fix #4 (commit cc62e1c) divided the observed horizSpeed by WalkAnimSpeed
(3.12 m/s) when computing the sidestep speedMod. That made slow strafe
come out 2.5× too slow because retail's sidestep cycle uses
SidestepAnimSpeed (1.25 m/s) — a smaller base — per
MotionInterpreter.cs:592 `velocity.X = SidestepAnimSpeed * SideStepSpeed`.

User report: "Strafe left and right slowly now is SUPER slow :)".

Replace MotionInterpreter.WalkAnimSpeed with MotionInterpreter.SidestepAnimSpeed
in the sidestep branch only. Forward / backward branches continue using
WalkAnimSpeed (correct for those motions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:40:50 +02:00
Erik
cc62e1cfde fix(motion): #39 — handle backward sign + sidestep in ApplyPlayerLocomotionRefinement
User report from fix #3 visual verify (commit 2653b30):
- Forward Run↔Walk Shift toggle: WORKS now
- Strafe Shift toggle: no transition (was out of scope)
- "When I shift walk backwards, the retail char gets animated walking
  slow forward but blipping backwards" — REGRESSION

Root cause of the backward regression: ACE encodes WalkBackward as
`WalkForward` motion with NEGATIVE speedMod (MovementData.cs:115
`interpState.ForwardSpeed *= -0.65f`). My fix #1's hysteresis branches
treated lowByte 0x05 / 0x07 as "forward" and computed positive speedMod
from horizSpeed, overwriting the negative sign. Result: animation
played forward-walk while body kept moving backward (the rubber-band).

Strafe gap: sidestep (low byte 0x0F / 0x10) wasn't in fix #1's scope,
so ApplyPlayerLocomotionRefinement returned early for sidestep cycles.
Retail does the same wire-silence on Shift toggle for sidestep, so
observer-side cycle refinement must also fire for it.

Fix:
- Probe `currentSign = sign(CurrentSpeedMod)` to detect backward direction
- For sidestep (lowByte 0x0F or 0x10): keep motion ID, refine
  speedMod magnitude = horizSpeed / WalkAnimSpeed, preserve sign
- For backward (forward-class lowByte AND currentSign < 0): keep
  WalkForward motion (per ACE encoding), refine magnitude, preserve
  negative sign — no "RunBackward" motion exists, only |speedMod|
  changes between Walk-back (~0.65) and Run-back (~1.91 = runRate × 0.65)
- Forward (currentSign >= 0): existing Walk↔Run hysteresis unchanged

Build clean. Diagnostics: [UPCYCLE_PLAYER] line still prints; the
new sidestep / backward branches use the same SetCycle call so
their decisions appear in [SCFULL] / [CURRNODE] for inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:36:22 +02:00
Erik
2653b307c7 fix(motion): #39 — wire ApplyServerControlledVelocityCycle into player-remote path
Visual verify with the proper Shift-toggle scenario revealed that fix #1's
ApplyPlayerLocomotionRefinement was UNREACHABLE for player remotes — the
L.3 M2 routing at line 3626+ returns at line 3755, BEFORE the call site
at line 3879. The legacy NPC-only block that compute server velocity +
calls ApplyServerControlledVelocityCycle never runs for players.

[UPCYCLE_PLAYER] count = 0 in launch-39-fix2.log and launch-39-diag2.log
proved the velocity-fallback path was completely dead code for players.

Wire-level evidence (launch-39-diag2.log):

- [FWD_WIRE] for retail actor 0x50000001 over a clean Hold-W-press-Shift-
  release-Shift-release-W test shows ONLY Ready→Run and Run→Ready
  transitions. NO Walk wire transitions for the Shift toggle. So retail's
  outbound MoveToState logic does NOT emit a fresh packet on HoldKey-only
  changes (refutes the launch-39-fix2 hypothesis that both directions
  emit; the earlier fix2 log's many Walk↔Run transitions came from
  W press/release cycles WITH Shift held continuously, not from Shift
  toggling alone).
- [VEL_DIAG] over the same test shows clear walk-pace (~2.5 m/s) and
  run-pace (~11.5 m/s) periods, so the actor's actual physical speed
  IS changing despite the wire silence.

Fix: in OnLivePositionUpdated's L.3 M2 player-remote block, after the
near-enqueue / far-snap routing and before the early `return`, compute
synth velocity from PrevServerPos / LastServerPos and call into
ApplyServerControlledVelocityCycle. The function's internal routing
(commit 8fa04af) sends player remotes through ApplyPlayerLocomotionRefinement
which has the 500 ms UM grace + forward-direction + hysteresis logic
to flip Run↔Walk only when no fresh UM is authoritative.

Build clean. Diagnostics: [UPCYCLE_SRC] now prints `src=synth-player`
when the player-remote path fires (distinct from `src=synth`/`src=wire`
in the legacy NPC path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:25:10 +02:00
Erik
bb026b7991 diag(motion): #39 — per-tick [CURRNODE] for sequencer node identity
Visual-verify of fix #2 (commit 863d96b) showed [SCFULL] correctly reports
currNodeIsCyclic=True after each direct Walk↔Run SetCycle (the link is
removed and _currNode is set to _firstCyclic). User report still:

- Animation continues running visually after Shift toggle to Walk
- Body slows ("speed decreases"), causing rubber-banding
- Adding a turn motion in that state makes the cycle finally transition
  to walking

So either:
- _currNode is reset to a stale node BETWEEN SetCycle and Advance
- _currNode is correctly on the new cycle but its AnimRef is wrong
  (e.g., the same Animation as the previous cycle, dat-side issue)
- BuildBlendedFrame reads from somewhere other than _currNode

Adds CurrentNodeDiag + FirstCyclicAnimRefHash properties on
AnimationSequencer that expose the active node's Animation
identity-hash, IsLooping, Framerate, frame range, and FramePosition.
TickAnimations logs them on every SEQSTATE tick (1 Hz throttle, gated
on ACDREAM_REMOTE_VEL_DIAG=1).

The [CURRNODE] line with animRef vs firstCyclicAnimRef proves whether
_currNode is actually on the new cycle's anim or has drifted to
something else. Compared across SetCycle SCFULL log lines + the
following CURRNODE ticks, we can see the exact moment the cycle
diverges from what SetCycle set.

No code-behavior changes. Pure read-only instrumentation. Per
Phase 4.5 of systematic-debugging — STOP attempting fixes; gather
evidence first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:17:56 +02:00
Erik
863d96bb23 fix(motion): #39 — skip transition link for cyclic→cyclic locomotion in SetCycle
Root cause identified by research-agent read of AnimationSequencer.SetCycle
+ Advance + the per-tick TickAnimations call site:

- SetCycle enqueues transition link + new cycle, then forces _currNode
  onto firstNew (the LINK), per the 357dcc0 fix that pinned _currNode
  to the most-recently-enqueued node.
- Advance plays the link to completion (~100–300 ms at Framerate 30 ×
  link runSpeed) before AdvanceToNextAnimation moves _currNode forward
  to the cycle.
- For Walk↔Run direct toggles faster than the link's drain time, the
  next UM arrives, SetCycle restarts _currNode on a fresh link, and
  the cycle node at the queue tail is never reached.
- BuildBlendedFrame returns frames from the link the entire time —
  user observes the link's interpolation pose ("blips forward in
  walking animation"), never the new Walk or Run cycle.

Confirmed by [SCFULL] currNodeIsCyclic=False after every direct
Walk↔Run transition in launch-39-candidate.log.

Fix: when prev motion AND new motion are both locomotion cycles
(WalkForward, WalkBackward, RunForward, SideStep L/R), land
_currNode on _firstCyclic (the new cycle node) instead of firstNew
(the link), and remove the just-enqueued link from the queue.
Conditional on BOTH being locomotion to avoid regressing cases that
DO need the link to play:
- Idle→Run (link is the wind-up pose)
- Falling→Ready (landing animation)
- Ready→Sitting/Crouching/Sleeping
- Combat substates (attack/parry/ready transitions)

Reverted commit c06b6c5 demonstrated that unconditional link skip
breaks all of those — this fix is narrower.

Retail reference: cdb live trace 2026-05-03 of a Walk→Run direct
transition logged add_to_queue(45000005) followed by
add_to_queue(44000007) with truncate_animation_list never firing —
matching the observed semantics this fix implements.

42/42 AnimationSequencer tests pass. The 8 pre-existing test
failures elsewhere on the branch (BSPStepUp, MotionInterpreter
WalkBackward, etc.) are unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 07:28:21 +02:00
Erik
8fa04af4c7 fix(motion): #39 candidate — un-gate UP velocity-cycle for player remotes (forward only)
Adds a player-remote velocity-fallback path to ApplyServerControlledVelocityCycle
so that when retail (the actor) toggles Shift while holding W and acdream is
the observer, the visible leg cycle switches Run↔Walk within ~200–500 ms even
though no fresh UM arrives. Static analysis (ACE GameActionMoveToState +
MovementData.cs auto-upgrade + acdream's prior diag traces) suggests retail
does NOT broadcast a fresh MoveToState on HoldKey-only changes — acdream's
UMs handle direction-key changes and our local +Acdream's transitions, but
retail-driven actors leave the cycle stuck.

Changes (all in src/AcDream.App/Rendering/GameWindow.cs):
- New RemoteMotion.LastUMTime field, stamped in OnLiveMotionUpdated
- ApplyServerControlledVelocityCycle: removed inner IsPlayerGuid gate;
  routes player remotes to new ApplyPlayerLocomotionRefinement
- ApplyPlayerLocomotionRefinement (forward-direction only):
  - 500 ms UM grace window (UMs win when fresh)
  - Forward-direction-only (low byte 0x05 / 0x07)
  - Hysteresis: Run → Walk demote at < 4.5 m/s; Walk → Run promote > 5.5 m/s
  - Skip SetCycle when neither motion ID nor speedMod changed meaningfully
  - [UPCYCLE_PLAYER] diag gated on ACDREAM_REMOTE_VEL_DIAG=1
- Outer call site in OnLivePositionUpdated un-gated (!IsPlayerGuid removed);
  per-remote routing now lives inside the function

Scope: case #1 (Run↔Walk forward) only. Cases #2–#7 (backward, sidestep
speed-buckets, direction-flips) remain deferred — PlanFromVelocity is
forward-only and its NPC-tuned thresholds (RunThreshold=1.25) do not
separate player Walk (~2.5 m/s) from player Run (~9 m/s); a TTD trace
of retail's per-direction algorithm should ground the wider fix.

ISSUES.md #39 updated with progress; investigation-prompt.md and a new
findings-static.md committed under
docs/research/2026-05-06-locomotion-cycle-transitions/ (the prompt was
authored on a parallel branch in commit 7a38da3 and is brought into this
worktree here so the next session can find it without branch-hopping).

Build clean. The 8 pre-existing test failures on this branch
(BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope, MotionInterpreter
WalkBackward GetMaxSpeed, etc.) are unrelated to this change — verified
by running them with the diff stashed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:34:20 +02:00
Erik
9e4772a8f8 fix(motion): project anim root motion onto terrain plane (slope staircase)
Grounded player remotes were showing a ~5 Hz Z staircase when running
up/down slopes — the rate of server UpdatePositions. Body Z stayed flat
between UPs, then ramped over ~100ms during the queue-active chase to
each new server position, then went flat again until the next UP.

Diagnosis (no diagnostic needed — the math is unambiguous):
PositionManager.ComputeOffset has two modes via
InterpolationManager.AdjustOffset:

  - Queue active (body chasing a waypoint): returns
    `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction,
    Z follows server's reported Z naturally.
  - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the
    most recent UP): returns Vector3.Zero. ComputeOffset falls back to
    `seqVel × dt rotated into world` — pure animation root motion. Every
    locomotion cycle bakes Z=0 in body-local, so the world result has
    Z=0 too. XY advances at the running pace; Z stays at the last UP.

For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and
server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms
(queue-active 3D path, Z ramps), then sits in seqVel-only mode for
~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase.

Fix mirrors retail's CTransition::adjust_offset contact-plane projection
(named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
motion, applied at the queue-empty boundary instead of inside the sweep:

  PositionManager.ComputeOffset gains an optional Vector3? terrainNormal.
  When the seqVel-only fallback runs AND a non-trivial terrain normal is
  supplied, project rootMotionWorld onto the plane:

      result = rootMotionWorld − N × dot(rootMotionWorld, N)

  Anim XY motion gains a corresponding Z component proportional to slope
  angle × forward speed, so body Z follows the terrain mesh between UPs.
  No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's
  flat-ground verification.

GameWindow.TickAnimations grounded-remote path samples
PhysicsEngine.SampleTerrainNormal at the body's current XY each tick
and passes it to ComputeOffset. SampleTerrainNormal is a thin public
wrapper over the existing internal SampleTerrainWalkable that returns
just the plane normal (no need to expose the internal sample shape).

Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with
guid, body Z before/after, offset, queue active flag, and the sampled
plane Nz so we can grep before/after the fix and confirm Z changes
continuously between UPs on slopes.

Tests: PositionManagerTests gains two cases:
  - slope projection: 30° east-tilted plane, body running due east at
    4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not
    flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866)
    × 2.0 = (3.0, 0, −1.732).
  - flat-ground no-op: N = +Z, expect identical Y-only motion as the
    pre-fix behavior.

Build green. 357 pass / 6 pre-existing fail (same set as ec59a08;
verified by stashing this change). The pre-existing
`ComputeOffset_BothActive_Combined` failure reflects an outdated
additive-design test docstring; the M2 commit (40d88b9) deliberately
changed the implementation to REPLACE semantics to fix the prior
3×-server-pace overshoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:37:42 +02:00
Erik
ec59a08db5 fix(physics): #42 skip self in FindObjCollisions — airborne XY drift
Root cause confirmed via two-run diagnostic and the named-retail decomp:
the airborne sweep was colliding with the moving entity's OWN ShadowEntry
because FindObjCollisions had no self-skip filter. Live entities (local
player, remotes) register a Cylinder in ShadowObjectRegistry on spawn
(GameWindow.cs:2545) and UpdatePosition tracks its world position each
tick, so the moving sphere's own cylinder is always at the body's
position. Without a gate, CylinderCollision sees the sphere overlapping
its own cylinder volume and slides the sphere ~1m horizontally on every
frame the path produces non-zero motion.

Why grounded mostly hides it and airborne exposes it:
- Stationary grounded → numSteps=0, TransitionalInsert never runs.
- Walking grounded → push fires but motion escapes the cyl radius and
  the deflection blends into normal motion.
- Stationary airborne (jump) → pure +Z motion; the cyl push is the
  only horizontal contribution and manifests as a clean ~1m drift.

Run-2 evidence (launch-42-r2.log) — 152 [SWEEP-OBJ] events, every one
with type=Cylinder, gfxObj=0x02000001 (humanoid setup), R=0.679,
H=1.835, at obj.Position EXACTLY matching the body's pre.Position. Run
1 had already ruled out H1 (cpN=(0,0,1) flat, no slope projection).

Retail does the same skip — CObjCell::find_obj_collisions at
named-retail acclient_2013_pseudo_c.txt:308931:

    if ((physobj->parent == 0 && physobj != arg2->object_info.object))

`arg2->object_info.object` is the OBJECTINFO::object self-pointer set
by OBJECTINFO::init at acclient_2013_pseudo_c.txt:274435. Our port
mirrors this with an EntityId-based filter:

  - ObjectInfo gains a SelfEntityId field (default 0 = no filter).
  - ResolveWithTransition gains an optional `uint movingEntityId = 0`
    parameter that sets it.
  - FindObjCollisions skips entries whose EntityId matches
    SelfEntityId when the id is non-zero.
  - PlayerMovementController gains a LocalEntityId property; GameWindow
    refreshes it per-tick from `_entitiesByServerGuid[_playerServerGuid]`.
  - GameWindow's airborne-remote ResolveWithTransition call site passes
    `movingEntityId: kv.Key` (kv.Key is the local entity id keying
    `_animatedEntities`, same id used at the spawn-time
    ShadowObjects.Register).

Default 0 keeps tests and one-shot callers (no registered ShadowEntry)
working unchanged.

Lock-the-fix unit test:
`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`
registers a humanoid Cylinder at the body's exact position (matching
GameWindow's spawn pattern), then asserts that:
  - movingEntityId=0 (control)        → unfiltered XY drift > 0.5m
  - movingEntityId=registered id (fix) → XY drift ≈ 0

Diagnostic wiring (a36369d + this commit's [SWEEP-OBJ] addition) stays
in tree, env-var gated (ACDREAM_AIRBORNE_DIAG=1) so it produces no
output in normal use but lets us verify the fix on the live client and
debug future regressions.

Build: green. Tests: 355 pass, 6 fail (all pre-existing per the handoff
prompt — verified by stashing this change; the BSPStepUp C3 failure is
on the prior commit too).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:01:07 +02:00
Erik
a36369d8ca diag(physics): #42 add ACDREAM_AIRBORNE_DIAG [SWEEP] trace
Phase 1 of #42 root-cause investigation per the handoff doc. We
A/B confirmed (commit b37b713) that the ~1m XY drift on retail-
observed stationary jumps comes from inside ResolveWithTransition
when the per-tick airborne sweep runs (CellId fix at GameWindow.cs
3467). What we don't yet know: whether the drift originates in
H1 (initial-overlap depenetration along a tilted-terrain normal),
H2 (step-down probe firing despite isOnGround=false), or H3
(EdgeSlide on near-vertical motion grazing a wall).

This diagnostic gates a one-line Console trace on
ACDREAM_AIRBORNE_DIAG=1 AND !isOnGround so it doesn't pollute
grounded movement, and prints:

  [SWEEP] airborne pre=(...) target=(...) post=(...)
          cell=PRE->POST ok=BOOL deltaXY=(dx,dy)
          cp=valid|none cpN=(nx,ny,nz)

deltaXY = post - target — for a clean stationary +Z jump we
expect (0,0). Non-zero with cp=valid and a tilted cpN confirms
H1; non-zero direction tracking actor facing instead of terrain
orientation points to H2/H3.

Code-walk findings recorded for the next investigation pass:
- K-fix7 already prevents seeding ContactPlane on entry for
  airborne (PhysicsEngine.cs:493-519), so step 0's AdjustOffset
  cannot consume a stale plane.
- BUT ValidateWalkable can still SET ContactPlane during step 0's
  collision pass via the "below plane" branch (TransitionTypes.cs
  1320-1352) when sphere lowPoint dips below the tilted terrain
  triangle. Step 1's AdjustOffset would then consume that fresh
  plane and the "moving away from contact plane" branch
  (TransitionTypes.cs:1749-1754) projects the +Z offset along the
  slope normal, redirecting Z motion into XY.
- Step-down branch is correctly gated on oi.Contact (matches
  retail CTransition::transitional_insert at named-retail
  acclient_2013_pseudo_c.txt:273249, "(state & 1) == 0" returns
  OK without firing step-down).
- Retail's IS_VIEWER_OI=0x4 branch in OBJECTINFO::validate_walkable
  (acclient.h:6185) is never set anywhere in the named decomp,
  so the airborne path runs the same code in retail as in acdream.

User repros at flat plaza / east hillside / north hillside; the
direction-correlation of deltaXY with local terrain orientation
identifies which hypothesis is firing.

Build green; 13 PhysicsEngine tests green. No behavior change
when ACDREAM_AIRBORNE_DIAG is unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:16:29 +02:00
Erik
086e65dfe6 Merge L.3 motion port — queue-only chase for grounded player remotes
Brings the elated-aryabhata-208d5e branch into main. 7 commits implementing
the L.3 retail-faithful remote-entity motion port:

  de129bc  M1  Fresh InterpolationManager port + retail spec
  40d88b9  M2  Queue routing for player-remote UPs + entity-position sync
  2365c8c  M3  Animation root motion fallback for idle queue
  d57ace0  M6  Cleanup — dead fields + stale env-var references
  c26bbbb  M4  Jump-CellId fix + #42 filed
  b37b713      #42 root cause confirmed via A/B test
  5cc2812      Handoff prompt for #42 PhysicsEngine investigation

User-verified visual checks: smooth body chase on running/walking/strafing,
no per-UP rubber-band, no slope staircase, NPCs pathing correctly, jumps
land cleanly. Two follow-up issues filed:

  #41  sub-decimeter steady-state blips (velocity-synthesis residual; LOW)
  #42  airborne XY drift on jumps (PhysicsEngine.ResolveWithTransition
       depenetration; root cause confirmed; deep-dive prompt at
       docs/research/2026-05-05-issue-42-handoff.md)

Replaces the env-var-gated experimental path (ACDREAM_INTERP_MANAGER=1)
which was marked DO-NOT-ENABLE — the env-var no longer toggles anything.
NPCs and airborne player remotes still use the legacy path; only grounded
player remotes route through the new retail-faithful queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:51:29 +02:00
Erik
b37b7137f6 docs(motion): #42 root cause confirmed — ResolveWithTransition airborne drift
A/B-tested 2026-05-05 with user observing retail-controlled remote:

  - With CellId fix removed: jumps render with geometrically-correct
    XY (no drift) but body falls through the floor.
  - With CellId fix applied: jumps land cleanly but arc shows ~1 m
    horizontal offset; snaps back on next UM.

Confirms the drift originates inside ResolveWithTransition, not from
wire data, local Euler error, or stale velocity. CellId fix kept in
place because falling through the floor is more disruptive than
~1 m visual jitter that resolves on next input.

#42 updated with the verified diagnosis, three ranked-by-probability
hypotheses for the in-sweep mechanism (initial-overlap depenetration
along non-+Z terrain normal is the leading candidate), three matching
fix paths, and a deterministic repro recipe for the next session.

The right next step is investigating PhysicsEngine.ResolveWithTransition
and comparing against retail's CTransition::find_valid_position
(docs/research/named-retail/) — out of scope for the L.3 motion port,
files as a follow-up PhysicsEngine bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:47:40 +02:00
Erik
c26bbbb84e fix(motion): L.3 M4 jump-CellId + file #42 airborne XY drift
CellId fix:

L.3 M2 introduced OnLivePositionUpdated player-remote routing that
returned without setting `rmState.CellId = p.LandblockId`. The legacy
path always set this (formerly at line 3601). Airborne player remotes
fall through to the legacy TickAnimations path which gates
ResolveWithTransition on `rm.CellId != 0`; without the cell-id update
the sphere sweep was skipped, K-fix15 landing detection never fired,
and the body fell through the floor on jumps.

Fix: set `rmState.CellId = p.LandblockId` early in the M2 player-remote
branch (after orientation snap, before any return).

User-verified 2026-05-05: jumps now land cleanly with sequencer
leaving Falling on landing.

#42 filed:

Visual verification of M4 also exposed a ~1 m horizontal drift on
stationary jumps (body arcs through the air offset from actor's actual
position; lands at offset; snaps back on next UM). User confirms this
is pre-existing, masked by the legacy path's hard-snap-on-every-UP
behavior that M2 explicitly removed per retail spec
(03-up-routing.md § 3 "AIRBORNE NO-OP"). Filed as #42 with three
candidate fix paths (pragmatic legacy-restore, root-cause investigation,
or hybrid soft-correction).

M5 NPCs verified clean (legacy path unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:35:42 +02:00
Erik
d57ace0177 chore(motion): L.3 M6 — scrub stale ACDREAM_INTERP_MANAGER + dead fields
Cleans up dead code revealed by L.3 M2/M3:

GameWindow.cs:
- RemoteMotion.LastServerZ field deleted (only consumed by the M2-
  removed Step 5 landing fallback in TickAnimations; never read).
- RemoteMotion.TargetOrientation field deleted (audit § 1 flagged as
  DEAD; only ever written, never read).
- Stale ACDREAM_INTERP_MANAGER comments removed from RemoteMotion.Interp
  and OnLivePositionUpdated (the env-var no longer gates anything as
  of M2).
- Doc-comments on Interp + Position rewritten to describe the M2/M3
  production semantics (queue catch-up + REPLACE-style combiner).

CLAUDE.md:
- ACDREAM_INTERP_MANAGER env-var entry rewritten as a retirement note
  pointing at commit 40d88b9 (M2). The path it gated is now the
  default for player remotes.

Build green, dotnet test green (8 pre-existing failures unchanged on
this baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:20:50 +02:00
Erik
2365c8cd6e feat(motion): L.3 M3 — animation root motion fallback for idle queue
Restores PositionManager.ComputeOffset call in TickAnimations player-
remote branch. M2 was queue-only (body chases server but stops between
UPs after head reached); M3 adds the retail REPLACE behavior:

  - Queue active and not reached → catch-up vector (REPLACES anim).
  - Queue empty or head reached → anim root motion (seqVel × dt rotated
    by body.Orientation) drives translation between UPs.
  - Blip-to-tail still fires on fail_count > 3.

Mirrors retail UpdatePositionInternal @ 0x00512c30 per
docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md
§ 6: PositionManager::adjust_offset OVERWRITES local frame's origin
with catch-up when active; otherwise no-op (anim root motion stands).

User-verified 2026-05-05: "Best implementation we have had so far.
Running works, walking works, strafing works."

Closes #40 (env-var path regression — replaced wholesale).
Files #41 for residual sub-decimeter blips: velocity-synthesis magnitude
(RunAnimSpeed × adjustedSpeed) overshoots server pace slightly, queue
walks it back every UP. Within retail's DesiredDistance / MinDistance
tolerances; not a correctness bug. Fix path requires porting
add_motion @ 0x005224b0 and cdb-tracing retail's actual
CSequence::velocity magnitude.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:17:51 +02:00
Erik
40d88b92ed feat(motion): L.3 M2 — queue-only chase for grounded player remotes
Wires the M1 InterpolationManager into the per-tick + UP-receipt paths
in GameWindow for player remote entities. Visual-verified against a
retail-controlled remote: smooth body chase, no per-UP rubber-band, no
staircase on slopes.

OnLivePositionUpdated:
- Gate changed from `ACDREAM_INTERP_MANAGER == "1"` to
  `IsPlayerGuid(update.Guid)`. NPCs continue through the legacy
  synth-velocity branch (ServerVelocity / ServerMoveTo) below — their
  motion model is correct as-is.
- Within-bubble enqueue passes `currentBodyPosition` so the M1 far-
  branch detection (>100 m from body) can pre-arm an immediate blip.
- Three branches (airborne no-op, near-enqueue, far-snap) now sync
  `entity.Position = rmState.Body.Position` before returning. This
  overrides the unconditional `entity.Position = worldPos` snap at
  the top of the function. Without this sync the entity teleports
  forward to server truth on UP receipt and TickAnimations yanks it
  back to the queue-driven body next frame — visible 0.5–1 m rubber-
  band per UP.

TickAnimations:
- Gate changed from `ACDREAM_INTERP_MANAGER == "1"` to
  `IsPlayerGuid(serverGuid) && !rm.Airborne`. Airborne player remotes
  fall through to the legacy path so K-fix15 landing + gravity sweep
  still fire on the jump arc.
- Step 2 (per-frame translation) replaced. Was
  `rm.Position.ComputeOffset(...)` (mixed queue catch-up + animation
  root motion); now direct `rm.Interp.AdjustOffset(...)` (queue-only,
  no anim contribution). M3 will layer anim root motion on top so
  legs match body pace; for M2 the body chases server position
  smoothly without any anim-driven translation.
- Step 4b (ResolveWithTransition collision sweep) REMOVED for player
  remotes. Server already collision-resolved the broadcast position;
  running the sweep on tiny per-frame queue catch-up deltas amplified
  micro-bounces into the ISSUES.md #40 staircase + flat-ground blips.
- Step 5 (LastServerZ landing fallback) REMOVED — unreachable in the
  `!rm.Airborne` branch.

Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
04-interp-manager.md): m_velocityVector stays 0 for grounded remotes,
apply_current_movement is local-player-only, and per-tick translation
comes entirely from InterpolationManager queue catch-up.

Behavior for player remotes:

  | Scenario              | Path   | Translation source           |
  |-----------------------|--------|------------------------------|
  | Grounded near (≤96m)  | M2     | Queue catch-up (2× max-speed)|
  | Grounded far (>96m)   | M2     | Hard-snap to worldPos        |
  | Far enqueue (>100m)   | M2     | Pre-armed blip-to-tail       |
  | Airborne (mid-jump)   | Legacy | Gravity arc + sweep          |
  | Landing               | M2     | Hard-snap, queue cleared     |

NPCs: legacy path unchanged (synth velocity, ServerMoveTo, etc.).

Closes the regression observed in 9b0f4f2 ("modern, not retail-faithful")
and the L.3 attempts on 91bf1e0 / e94e791. Replaces the env-var path
(ACDREAM_INTERP_MANAGER=1) which was marked DO-NOT-ENABLE in
ISSUES.md #40 — the env-var no longer toggles anything for player
remotes; this IS the path now.

Build green, dotnet test green (8 pre-existing failures unchanged on
this baseline; verified via stash on a3f53c2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:57:17 +02:00
Erik
de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00
Erik
5937ebe1c5 docs(issues): #37 — Investigation 2 narrows bug to SubPalette coverage gaps
Five parallel agents + dat probes ruled out:
- byte-level decode primitive (matches ACViewer)
- polygon emission (no ST_DOUBLE / Surface.Type & 6 issues)
- per-PART texture-override scoping (correctly per-MeshRef'd)
- SubPalette indexing convention (full-size 2048 palettes, *8 wire un-pack
  is single-applied)

Smoking gun: for +Acdream the server sends 10 SubPaletteSwap ranges that
overlay palette indices [0..320), [576..1024), [1392..1488), [1728..1920).
The complement — [320..576), [1024..1392), [1488..1728), [1920..2048) —
is NOT overlaid. Base palette 0x0400007E at those indices has
red/skin tones. Coat texture UVs sampling those non-overlaid indices
render as visible "skin stub at top of coat".

Either ACE sends incomplete SubPaletteSwap data, or retail does extra
client-side ClothingTable computation we (and ACE) don't.

Diagnostic harness now lives at tools/InspectCoatTex/Program.cs;
GameWindow's DUMP_CLOTHING also probes runtime SubPalette dat sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:45:50 +02:00
Erik
a3f53c2644 docs+cleanup: env-var regression + Run↔Walk cycle bug filed; re-throttle diags
End-of-session cleanup of the 2026-05-03 remote-motion debug session.

Documentation:

- CLAUDE.md: add ⚠️ DO-NOT-ENABLE warning for ACDREAM_INTERP_MANAGER=1
  in the diagnostic env-vars list. Add an "Outbound motion wire format"
  section documenting acdream's WalkForward+HoldKey.Run encoding (which
  ACE auto-upgrades to RunForward on relay) so future sessions don't
  re-derive it.

- docs/ISSUES.md: file two new issues:
  * #39 — Run↔Walk cycle transition not visible on observed
    retail-driven player remotes when watched from acdream. Root cause
    located: ApplyServerControlledVelocityCycle is gated by
    IsPlayerGuid, excluding the exact case where ACE doesn't broadcast
    a UM (shift toggle while direction key held). Fix sketch ~10
    lines, separate commit. Cross-references the four-agent
    investigation prompt.
  * #40 — ACDREAM_INTERP_MANAGER=1 env-var path regressed. Documents
    why (e94e791 conflated MoveOrTeleport with update_object), the
    visible symptoms (staircase Z, position blips), and why
    Commit B (039149a)'s ResolveWithTransition port was insufficient
    (env-var path also clears body.Velocity → no horizontal Euler
    motion → sweep input is queue catch-up only, which stair-steps).
    Fix path = separate L.3 follow-up to re-integrate PositionManager
    additively on top of the legacy chain.

Code:

- GameWindow.cs:6042: prepend a ⚠️ REGRESSED warning block at the top
  of the env-var per-frame branch so anyone reading the code is
  immediately aware not to enable it. Cross-references ISSUES.md #40.

- AnimationSequencer.cs: re-throttle [SCFAST]/[SCFULL] diagnostics to
  0.5s per instance (rolled back from A.1's unthrottled experiment).
  Already served its purpose; throttled is enough for steady-state
  diagnostics. Restore _lastSetCycleDiagTime field.

No behavior change for any current launch (env-var unset = legacy
path unchanged). Build green; baseline test failures (8) unchanged
from prior commit, none introduced by this session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:10:10 +02:00
Erik
039149a9d0 fix(motion): port ResolveWithTransition into env-var per-tick path (Commit B)
Restores per-frame collision/terrain sweep that was DROPPED by e94e791
(L.3.1+L.3.2 Task 3) when the ACDREAM_INTERP_MANAGER=1 path replaced
the per-tick logic with a stripped-down version intended to mirror
retail's CPhysicsObj::MoveOrTeleport.

That was a category error: MoveOrTeleport (acclient @ 0x00516330) is
the *network packet handler* entry point — minimal work. The per-frame
physics tick is retail's update_object (FUN_00515020) — full chain
including FUN_005148A0 Transition::FindTransitionalPosition (the
collision sweep). The legacy (env-var off) path mirrors update_object
correctly; the env-var path was missing this single step.

Symptoms that map directly to the missing sweep:
  - "Staircase" Z drift on slopes (horizontal Euler motion sinks into
    rising ground until the next UP pops it up). User-confirmed for
    BOTH retail-driven AND acdream-driven remotes when observed from
    acdream.
  - Position blips during steady-state motion (predicted XY drifts
    unconstrained between UPs, then UP hard-snaps).

Fix: copy the legacy path's "Step 4: collision sweep" block (lines
~6483-6569) into the env-var per-frame branch, between
UpdatePhysicsInternal and the existing landing fallback. Includes
post-resolve landing detection (K-fix15 + K-fix17 mirror) so airborne
remotes correctly transition back to grounded after the sweep clamps
them to a walkable surface.

Sphere dims match the legacy path verbatim (0.48m radius, 1.2m height,
0.4m step-up/down, EdgeSlide moverFlags) — retail human-scale, already
proven via the legacy path before the e94e791 regression.

Does NOT address the separate Run↔Walk cycle bug (different root
cause: missing velocity-derived cycle inference for player remotes).
That's a follow-up commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 08:10:55 +02:00
Erik
eaa8fc5c67 diag(motion): A.1 — unthrottled SCFAST/SCFULL + UM_RAW (Commit A.1)
Commit A's log refuted H2 (UPCYCLE never fires for player guids — gated
by IsPlayerGuid), H4 (SCNULLFALLBACK count = 0), H5 (PartTemplate
counts always match anim PartFrames). The remaining puzzle:

  SCFULL Ready=23 dominates (all motions: 41 total)
  SETCYCLE picker logged: only 9 transitions to Ready

Difference (≥14 extra Ready full-rebuilds) suggests a non-picker source,
OR many UMs arriving with no ForwardCommand bit being routed through
the picker's `else if (!command.HasValue) { fullMotion = Ready; }` at
GameWindow.cs:2671-2673, knocking the cycle back to Ready mid-Walk/Run.

This commit removes the 0.5s throttle on SCFAST and SCFULL (every call
now logs) and adds [UM_RAW] at OnLiveMotionUpdated entry to print:
  - stance / fwd / fwdSpd / side / turn / movementType / isMoveTo
  - sequencer.CurrentMotion at call time
per UM, gated on ACDREAM_REMOTE_VEL_DIAG=1.

Combined: one repro pass tells us (a) UM arrival rate per remote, (b)
which UMs lack ForwardCommand, (c) whether the picker is the source of
the 14+ extra Ready calls. Commit B is then a one-line fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:51:14 +02:00
Erik
23004a4791 diag(motion): instrumentation for remote walk↔run leg-cycle bug (Commit A)
Adds five diagnostics, no behavior changes. All gated on existing
ACDREAM_REMOTE_VEL_DIAG=1 env var. Plan at
~/.claude/plans/yes-make-a-plan-parsed-axolotl.md.

Five hypotheses surviving from the four-agent investigation
(docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md):

  H1  SEQSTATE silently swallowed by OMEGA_DIAG sharing throttle clock
  H2  ApplyServerControlledVelocityCycle races UM-driven SetCycle per UP
  H3  SetCycle fast-path returns without updating _currNode
  H4  GetLink/GetCycle null → defensive fallback lands on stale head
  H5  PartTemplate.Count diverges from anim PartFrames.Count → silent
       identity-quat freeze

Diagnostics added (all log lines are grep-prefixed):

  D1  Split LastSeqStateLogTime field for SEQSTATE — own throttle.
      Foundational: every other diag depends on SEQSTATE telling truth.
  D2  [UPCYCLE] inside ApplyServerControlledVelocityCycle, +
      [UPCYCLE_SRC] at the call site (wire vs synth velocity).
  D3  [SCFAST] in fast-path return, [SCFULL] at full-rebuild end.
  D4  [SCNULLFALLBACK] in the null-data defensive fallback.
  D5  [PARTSDIAG] with pt.Count / seqFrames.Count / setup.Parts.Count /
       anim.PartFrames[0].Frames.Count + sum-of-components hash.

Repro recipe:

  $env:ACDREAM_INTERP_MANAGER  = "1"
  $env:ACDREAM_REMOTE_VEL_DIAG = "1"
  dotnet run … 2>&1 | Tee-Object tools/diag-logs/walkrun-<ts>.log

Then watch a retail-driven character through acdream and exercise:
idle → W run → release → shift+W walk → release → demote → promote →
run+turn (this last one is the H1 trap).

Decision matrix in the plan file maps each [TAG] signature to a
specific Commit B fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:38:47 +02:00